Compare commits
No files matched your search
@@ -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; }
|
||||
|
||||
@@ -6,6 +6,69 @@ publish a tag that has no section here.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
@@ -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,39 @@ 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.
|
||||
* **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 +878,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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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[:])}
|
||||
}
|
||||
|
||||
|
||||
+20
-36
@@ -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...)
|
||||
@@ -777,38 +793,6 @@ func caIntermediateCmd(cfg *Config, name string, args []string) error {
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,541 @@
|
||||
package main
|
||||
|
||||
// Cabling: what each machine's ports are plugged into, and where the
|
||||
// switches at the other end stand. A port's switch port comes from, first,
|
||||
// the LLDP its commissioning saw (lldpcli's neighbours per interface): what
|
||||
// is really there; then one entered here; then a cable recorded in NetBox on
|
||||
// the machine's device (dcim interfaces, through patch panels). The BMC's port
|
||||
// is not seen by the host's LLDP: its cable (to the out-of-band switch) is
|
||||
// NetBox's or entered here. When NetBox records another switch port than
|
||||
// LLDP sees, the port says so (netbox_says).
|
||||
//
|
||||
// A switch's place comes from NetBox (the device's site, location, rack and
|
||||
// position, read with the layout) or is set here; a switch with neither is
|
||||
// drawn where most of its machines are. A switch whose NetBox role names
|
||||
// out-of-band or management, or that only BMCs are cabled to, is "oob".
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Port struct {
|
||||
Name string `json:"name"`
|
||||
MAC string `json:"mac,omitempty"`
|
||||
Speed string `json:"speed,omitempty"`
|
||||
Link *bool `json:"link,omitempty"`
|
||||
Card string `json:"card,omitempty"` // PCI device without the function: the ports of one card
|
||||
Switch string `json:"switch,omitempty"`
|
||||
SwitchPort string `json:"switch_port,omitempty"`
|
||||
PortDescr string `json:"port_descr,omitempty"`
|
||||
VLAN string `json:"vlan,omitempty"`
|
||||
Kind string `json:"kind,omitempty"` // bmc for the BMC's own port
|
||||
Source string `json:"source,omitempty"` // lldp | netbox | local
|
||||
NetBoxSays string `json:"netbox_says,omitempty"` // NetBox's switch port when LLDP sees another
|
||||
}
|
||||
|
||||
// Cable is one recorded port-to-switch-port connection (NetBox's or entered here).
|
||||
type Cable struct {
|
||||
Machine string `json:"machine"`
|
||||
Port string `json:"port"` // an interface name, or "bmc"
|
||||
Switch string `json:"switch"`
|
||||
SwitchPort string `json:"switch_port"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type MachinePorts struct {
|
||||
ID string `json:"id"`
|
||||
Ports []Port `json:"ports"`
|
||||
}
|
||||
|
||||
type Switch struct {
|
||||
Name string `json:"name"`
|
||||
Descr string `json:"descr,omitempty"`
|
||||
MgmtIP string `json:"mgmt_ip,omitempty"`
|
||||
Building string `json:"building,omitempty"`
|
||||
Room string `json:"room,omitempty"`
|
||||
Rack string `json:"rack,omitempty"`
|
||||
Unit int `json:"unit,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Role string `json:"role,omitempty"` // oob for an out-of-band (BMC) switch
|
||||
Source string `json:"source,omitempty"` // netbox | local | "" (not placed)
|
||||
Links int `json:"links"`
|
||||
}
|
||||
|
||||
type lldpNeighbour struct{ Switch, Port, PortDescr, VLAN, Descr, MgmtIP string }
|
||||
|
||||
// lldpOf reads inventory "lldp": lldpcli's "interface", an object of
|
||||
// interfaces or a list of one-interface objects, each one entry or a list.
|
||||
func lldpOf(inv obj) map[string]lldpNeighbour {
|
||||
out := map[string]lldpNeighbour{}
|
||||
add := func(ifn string, v any) {
|
||||
for _, e := range asList(v) {
|
||||
eo := asObj(e)
|
||||
ch := asObj(eo["chassis"])
|
||||
var name string
|
||||
var c obj
|
||||
for k, v := range ch {
|
||||
name, c = k, asObj(v)
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
nb := lldpNeighbour{Switch: name, Descr: str(c["descr"]), PortDescr: str(dig(eo, "port", "descr"))}
|
||||
nb.Port = str(dig(eo, "port", "id", "value"))
|
||||
if nb.Port == "" {
|
||||
nb.Port = str(dig(eo, "port", "id"))
|
||||
}
|
||||
for _, ip := range asList(c["mgmt-ip"]) {
|
||||
if s := str(ip); s != "" && nb.MgmtIP == "" {
|
||||
nb.MgmtIP = s
|
||||
}
|
||||
}
|
||||
for _, v := range asList(eo["vlan"]) {
|
||||
if vo := asObj(v); vo != nil && nb.VLAN == "" {
|
||||
nb.VLAN = str(vo["vlan-id"])
|
||||
}
|
||||
}
|
||||
if _, seen := out[ifn]; !seen {
|
||||
out[ifn] = nb
|
||||
}
|
||||
}
|
||||
}
|
||||
switch l := inv["lldp"].(type) {
|
||||
case obj:
|
||||
for ifn, v := range l {
|
||||
add(ifn, v)
|
||||
}
|
||||
case []any:
|
||||
for _, x := range l {
|
||||
for ifn, v := range asObj(x) {
|
||||
add(ifn, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// portsOf: a machine's ports from its inventory, with the switch port each is cabled to
|
||||
func portsOf(m *Machine) ([]Port, map[string]lldpNeighbour) {
|
||||
inv := invOf(m)
|
||||
nb := lldpOf(inv)
|
||||
var out []Port
|
||||
for _, x := range toList(inv["network"]) {
|
||||
n := asObj(x)
|
||||
p := Port{Name: str(n["name"]), MAC: str(n["mac"]), Speed: str(n["speed"])}
|
||||
if p.Name == "" {
|
||||
continue
|
||||
}
|
||||
if b, ok := n["link"].(bool); ok {
|
||||
p.Link = &b
|
||||
}
|
||||
if p.Speed == "Unknown!" {
|
||||
p.Speed = ""
|
||||
}
|
||||
if bus := str(n["bus_info"]); strings.Count(bus, ":") == 2 { // 0000:3b:00.1 → 0000:3b:00
|
||||
p.Card, _, _ = strings.Cut(bus, ".")
|
||||
}
|
||||
if l, ok := nb[p.Name]; ok {
|
||||
p.Switch, p.SwitchPort, p.PortDescr, p.VLAN, p.Source = l.Switch, l.Port, l.PortDescr, l.VLAN, "lldp"
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].Card < out[j].Card })
|
||||
return out, nb
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- store
|
||||
|
||||
func (s *Store) Switches(ctx context.Context) ([]Switch, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT name, coalesce(building,''), coalesce(room,''), coalesce(rack,''), coalesce(unit,0), coalesce(height,1), coalesce(model,''), coalesce(role,''), source FROM switches ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Switch{}
|
||||
for rows.Next() {
|
||||
var x Switch
|
||||
if err := rows.Scan(&x.Name, &x.Building, &x.Room, &x.Rack, &x.Unit, &x.Height, &x.Model, &x.Role, &x.Source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, x)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PutSwitch places a switch; one placed by hand is not moved by NetBox.
|
||||
func (s *Store) PutSwitch(ctx context.Context, x Switch) error {
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO switches(name, building, room, rack, unit, height, model, role, source) VALUES ($1,$2,$3,$4,nullif($5,0),nullif($6,0),nullif($7,''),nullif($8,''),$9)
|
||||
ON CONFLICT (name) DO UPDATE SET building=$2, room=$3, rack=$4, unit=nullif($5,0), height=nullif($6,0), model=coalesce(nullif($7,''), switches.model),
|
||||
role=coalesce(nullif($8,''), switches.role), source=$9
|
||||
WHERE switches.source <> 'local' OR $9 = 'local'`, x.Name, x.Building, x.Room, x.Rack, x.Unit, x.Height, x.Model, x.Role, orDefault(x.Source, "local"))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Cables(ctx context.Context) ([]Cable, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT machine_id, port, switch, switch_port, source FROM cables ORDER BY machine_id, port`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Cable{}
|
||||
for rows.Next() {
|
||||
var c Cable
|
||||
if err := rows.Scan(&c.Machine, &c.Port, &c.Switch, &c.SwitchPort, &c.Source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PutCable records a cable; one entered here is not replaced by NetBox's.
|
||||
func (s *Store) PutCable(ctx context.Context, c Cable) error {
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO cables(machine_id, port, switch, switch_port, source) VALUES ($1,$2,$3,$4,$5)
|
||||
ON CONFLICT (machine_id, port, source) DO UPDATE SET switch=$3, switch_port=$4`, c.Machine, c.Port, c.Switch, c.SwitchPort, orDefault(c.Source, "local"))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteCable(ctx context.Context, machine, port, source string) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM cables WHERE machine_id=$1 AND port=$2 AND source=$3`, machine, port, source)
|
||||
return err
|
||||
}
|
||||
|
||||
// ReplaceNetBoxCables puts NetBox's cables in place of the ones read before.
|
||||
func (s *Store) ReplaceNetBoxCables(ctx context.Context, cs []Cable) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM cables WHERE source='netbox'`); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range cs {
|
||||
// a device whose rollcall_machine names no machine here (deleted, typed in) is skipped
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO cables(machine_id, port, switch, switch_port, source) SELECT $1,$2,$3,$4,'netbox'
|
||||
WHERE EXISTS (SELECT 1 FROM machines WHERE id=$1) ON CONFLICT DO NOTHING`, c.Machine, c.Port, c.Switch, c.SwitchPort); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSwitch(ctx context.Context, name string) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM switches WHERE name=$1`, name)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- handlers
|
||||
|
||||
// handleCabling: every located machine's ports and the switches they lead to.
|
||||
func (a *App) handleCabling(w http.ResponseWriter, r *http.Request) {
|
||||
ms, err := a.store.List(r.Context())
|
||||
if err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
placed, _ := a.store.Switches(r.Context())
|
||||
sw := map[string]*Switch{}
|
||||
key := func(name string) string { // an LLDP name may be the FQDN of a NetBox name
|
||||
k, _, _ := strings.Cut(strings.ToLower(name), ".")
|
||||
return k
|
||||
}
|
||||
for i := range placed {
|
||||
sw[key(placed[i].Name)] = &placed[i]
|
||||
}
|
||||
recorded, _ := a.store.Cables(r.Context())
|
||||
byMachine := map[string][]Cable{}
|
||||
for _, c := range recorded {
|
||||
byMachine[c.Machine] = append(byMachine[c.Machine], c)
|
||||
}
|
||||
nonBMC := map[string]bool{} // switches something other than a BMC is cabled to
|
||||
out := []MachinePorts{}
|
||||
for _, m := range ms {
|
||||
if m.Location.Rack == "" {
|
||||
continue
|
||||
}
|
||||
ports, nb := portsOf(m)
|
||||
ports = mergeCables(ports, byMachine[m.ID])
|
||||
get := func(name string) *Switch {
|
||||
s := sw[key(name)]
|
||||
if s == nil {
|
||||
s = &Switch{Name: name}
|
||||
sw[key(name)] = s
|
||||
}
|
||||
return s
|
||||
}
|
||||
for _, l := range nb { // what the switch says of itself
|
||||
s := get(l.Switch)
|
||||
s.Descr = orDefault(s.Descr, l.Descr)
|
||||
s.MgmtIP = orDefault(s.MgmtIP, l.MgmtIP)
|
||||
}
|
||||
for _, p := range ports {
|
||||
if p.Switch == "" {
|
||||
continue
|
||||
}
|
||||
s := get(p.Switch)
|
||||
s.Links++
|
||||
if p.Kind != "bmc" {
|
||||
nonBMC[key(p.Switch)] = true
|
||||
}
|
||||
}
|
||||
if len(ports) > 0 {
|
||||
out = append(out, MachinePorts{ID: m.ID, Ports: ports})
|
||||
}
|
||||
}
|
||||
for k, s := range sw {
|
||||
if s.Role == "" && s.Links > 0 && !nonBMC[k] {
|
||||
s.Role = "oob"
|
||||
}
|
||||
}
|
||||
for _, mp := range out { // switch names as the machines saw them, the place as stored
|
||||
for i := range mp.Ports {
|
||||
if s := sw[key(mp.Ports[i].Switch)]; s != nil && mp.Ports[i].Switch != "" {
|
||||
mp.Ports[i].Switch = s.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
list := []Switch{}
|
||||
for _, s := range sw {
|
||||
list = append(list, *s)
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name })
|
||||
writeJSON(w, 200, obj{"machines": out, "switches": list})
|
||||
}
|
||||
|
||||
// mergeCables adds the recorded cables to the ports LLDP filled: a port
|
||||
// without a neighbour takes the one entered here, else NetBox's; the BMC's
|
||||
// port is added when a cable is recorded for it. A NetBox cable that
|
||||
// disagrees with LLDP is kept as netbox_says.
|
||||
func mergeCables(ports []Port, cs []Cable) []Port {
|
||||
pick := func(port string) (nb, local *Cable) {
|
||||
for i := range cs {
|
||||
if strings.EqualFold(cs[i].Port, port) {
|
||||
if cs[i].Source == "netbox" {
|
||||
nb = &cs[i]
|
||||
} else {
|
||||
local = &cs[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
fill := func(p *Port) {
|
||||
nb, local := pick(p.Name)
|
||||
switch {
|
||||
case p.Source == "lldp":
|
||||
if nb != nil && (!strings.EqualFold(nb.SwitchPort, p.SwitchPort) || !sameSwitch(nb.Switch, p.Switch)) {
|
||||
p.NetBoxSays = nb.Switch + " " + nb.SwitchPort
|
||||
}
|
||||
case local != nil:
|
||||
p.Switch, p.SwitchPort, p.Source = local.Switch, local.SwitchPort, "local"
|
||||
case nb != nil:
|
||||
p.Switch, p.SwitchPort, p.Source = nb.Switch, nb.SwitchPort, "netbox"
|
||||
}
|
||||
}
|
||||
for i := range ports {
|
||||
fill(&ports[i])
|
||||
}
|
||||
bmc := Port{Name: "bmc", Kind: "bmc"}
|
||||
if fill(&bmc); bmc.Switch != "" {
|
||||
ports = append(ports, bmc)
|
||||
}
|
||||
return ports
|
||||
}
|
||||
|
||||
// sameSwitch: an LLDP name may be the FQDN of a NetBox name
|
||||
func sameSwitch(a, b string) bool {
|
||||
a, _, _ = strings.Cut(strings.ToLower(a), ".")
|
||||
b, _, _ = strings.Cut(strings.ToLower(b), ".")
|
||||
return a == b
|
||||
}
|
||||
|
||||
// handleCables records a cable by hand: {"machine","port","switch","switch_port"}
|
||||
// (port "bmc" for the BMC's); DELETE ?machine=&port= removes it.
|
||||
func (a *App) handleCables(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodDelete {
|
||||
q := r.URL.Query()
|
||||
if err := a.store.DeleteCable(r.Context(), q.Get("machine"), q.Get("port"), "local"); err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, obj{"deleted": q.Get("machine") + " " + q.Get("port")})
|
||||
return
|
||||
}
|
||||
var c Cable
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&c); err != nil || c.Machine == "" || c.Port == "" || strings.TrimSpace(c.Switch) == "" {
|
||||
fail(w, 400, errors.New("machine, port and switch are required"))
|
||||
return
|
||||
}
|
||||
if _, err := a.store.Get(r.Context(), c.Machine); err != nil {
|
||||
fail(w, 404, fmt.Errorf("machine %s: %w", c.Machine, err))
|
||||
return
|
||||
}
|
||||
c.Switch, c.SwitchPort, c.Source = strings.TrimSpace(c.Switch), strings.TrimSpace(c.SwitchPort), "local"
|
||||
if err := a.store.PutCable(r.Context(), c); err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, c)
|
||||
}
|
||||
|
||||
// handleSwitches places a switch by hand: {"name","building","room","rack","unit","height"};
|
||||
// DELETE ?name= forgets the place (NetBox's comes back on the next sync).
|
||||
func (a *App) handleSwitches(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodDelete {
|
||||
if err := a.store.DeleteSwitch(r.Context(), r.URL.Query().Get("name")); err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, obj{"deleted": r.URL.Query().Get("name")})
|
||||
return
|
||||
}
|
||||
var x Switch
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&x); err != nil || strings.TrimSpace(x.Name) == "" || x.Rack == "" {
|
||||
fail(w, 400, errors.New("name and rack are required"))
|
||||
return
|
||||
}
|
||||
if x.Unit < 0 || x.Unit > 60 || x.Height < 0 || x.Height > 10 {
|
||||
fail(w, 400, errors.New("unit 0..60, height 0..10"))
|
||||
return
|
||||
}
|
||||
x.Source = "local"
|
||||
if err := a.store.PutSwitch(r.Context(), x); err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, x)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- NetBox
|
||||
|
||||
// syncSwitches reads the cables NetBox records on Rollcall's devices, then
|
||||
// copies the place of every switch those and the machines' LLDP name that
|
||||
// NetBox has as a device with a rack.
|
||||
func (a *App) syncSwitches(ctx context.Context, nb *netBox) error {
|
||||
ms, err := a.store.List(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
names := map[string]bool{}
|
||||
cables, err := nb.cables(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cables: %w", err)
|
||||
}
|
||||
if err := a.store.ReplaceNetBoxCables(ctx, cables); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range cables {
|
||||
names[c.Switch] = true
|
||||
}
|
||||
for _, m := range ms {
|
||||
for _, l := range lldpOf(invOf(m)) {
|
||||
names[l.Switch] = true
|
||||
if short, _, ok := strings.Cut(l.Switch, "."); ok {
|
||||
names[short] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
var list []string
|
||||
for n := range names {
|
||||
list = append(list, n)
|
||||
}
|
||||
sort.Strings(list)
|
||||
var devs []obj
|
||||
for len(list) > 0 { // 50 names per request: a large site would outgrow a URL
|
||||
batch := list[:min(50, len(list))]
|
||||
list = list[len(batch):]
|
||||
got, err := nb.all(ctx, "dcim/devices/", url.Values{"name": batch})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
devs = append(devs, got...)
|
||||
}
|
||||
for _, x := range switchPlaces(devs) {
|
||||
if err := a.store.PutSwitch(ctx, x); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// switchPlaces: the devices that stand in a rack, as switches
|
||||
func switchPlaces(devs []obj) []Switch {
|
||||
var out []Switch
|
||||
for _, d := range devs {
|
||||
rack := str(dig(d, "rack", "name"))
|
||||
if rack == "" {
|
||||
continue
|
||||
}
|
||||
x := Switch{Name: str(d["name"]), Building: str(dig(d, "site", "name")), Room: str(dig(d, "location", "name")), Rack: rack,
|
||||
Unit: int(num(d["position"])), Height: 1, Model: str(dig(d, "device_type", "model")), Source: "netbox"}
|
||||
if role := strings.ToLower(str(dig(d, "role", "name")) + " " + str(dig(d, "role", "slug"))); strings.Contains(role, "oob") || strings.Contains(role, "out-of-band") || strings.Contains(role, "mgmt") || strings.Contains(role, "management") {
|
||||
x.Role = "oob"
|
||||
}
|
||||
out = append(out, x)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// bmcName: an interface NetBox models for the BMC (iDRAC, iLO, XCC, IMM, IPMI, BMC, OOB, mgmt)
|
||||
var bmcName = regexp.MustCompile(`(?i)^(idrac|ilo|xcc|imm|ipmi|bmc|oob|mgmt|management)`)
|
||||
|
||||
// cables: the cables on Rollcall's devices (tag and machine field) to
|
||||
// another device's interface, through patch panels (NetBox's connected
|
||||
// endpoint), as machine port → switch port; an interface
|
||||
// marked management-only or named like a BMC is the machine's "bmc".
|
||||
func (n *netBox) cables(ctx context.Context) ([]Cable, error) {
|
||||
devs, err := n.all(ctx, "dcim/devices/", url.Values{"tag": {n.tag}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
machineOf := map[int64]string{}
|
||||
var ids []string
|
||||
for _, d := range devs {
|
||||
if id := str(dig(d, "custom_fields", n.field)); id != "" && id != "-" {
|
||||
machineOf[nbID(d)] = id
|
||||
ids = append(ids, fmt.Sprint(nbID(d)))
|
||||
}
|
||||
}
|
||||
var out []Cable
|
||||
for len(ids) > 0 { // a page of devices per request
|
||||
batch := ids[:min(50, len(ids))]
|
||||
ids = ids[len(batch):]
|
||||
ifs, err := n.all(ctx, "dcim/interfaces/", url.Values{"device_id": batch, "cabled": {"true"}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, f := range ifs {
|
||||
m := machineOf[nbRef(f, "device")]
|
||||
peers, kind := f["connected_endpoints"], str(f["connected_endpoints_type"]) // the far end, through patch panels
|
||||
if kind != "dcim.interface" {
|
||||
peers, kind = f["link_peers"], str(f["link_peers_type"])
|
||||
}
|
||||
for _, p := range toList(peers) {
|
||||
po := asObj(p)
|
||||
sw := str(dig(po, "device", "name"))
|
||||
if m == "" || sw == "" || kind != "dcim.interface" {
|
||||
continue
|
||||
}
|
||||
port := str(f["name"])
|
||||
if f["mgmt_only"] == true || bmcName.MatchString(port) {
|
||||
port = "bmc"
|
||||
}
|
||||
out = append(out, Cable{Machine: m, Port: port, Switch: sw, SwitchPort: str(po["name"]), Source: "netbox"})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// lldpcli -f json show neighbors details: one interface is an object, several a list
|
||||
const lldpOne = `{"lldp": {"eno1": {"via": "LLDP", "chassis": {"leaf-1.dc1.example": {"id": {"type": "mac", "value": "0c:29:ef:00:00:01"},
|
||||
"descr": "Dell EMC Networking OS10", "mgmt-ip": ["10.0.0.11", "fe80::1"]}}, "port": {"id": {"type": "ifname", "value": "ethernet1/1/12"}, "descr": "node7 eno1"},
|
||||
"vlan": {"vlan-id": "100", "pvid": true}}}}`
|
||||
const lldpList = `{"lldp": [{"eno1": {"chassis": {"leaf-1": {"descr": "x"}}, "port": {"id": {"value": "Ethernet12"}}}},
|
||||
{"eno2": [{"chassis": {"leaf-2": {"mgmt-ip": "10.0.0.12"}}, "port": {"id": {"value": "Ethernet12"}}, "vlan": [{"vlan-id": "100"}, {"vlan-id": "200"}]}]}]}`
|
||||
|
||||
func TestLLDPShapes(t *testing.T) {
|
||||
var inv obj
|
||||
if err := json.Unmarshal([]byte(lldpOne), &inv); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := lldpOf(inv)
|
||||
if n := got["eno1"]; n.Switch != "leaf-1.dc1.example" || n.Port != "ethernet1/1/12" || n.VLAN != "100" || n.MgmtIP != "10.0.0.11" || n.PortDescr != "node7 eno1" {
|
||||
t.Fatalf("one: %+v", n)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(lldpList), &inv); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = lldpOf(inv)
|
||||
if got["eno1"].Switch != "leaf-1" || got["eno2"].Switch != "leaf-2" || got["eno2"].VLAN != "100" || got["eno2"].MgmtIP != "10.0.0.12" {
|
||||
t.Fatalf("list: %+v", got)
|
||||
}
|
||||
if len(lldpOf(obj{"lldp": nil})) != 0 {
|
||||
t.Fatal("no lldp should be no neighbours")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPortsOf(t *testing.T) {
|
||||
inv := `{"network": [{"name": "eno2", "mac": "aa:00:00:00:00:02", "bus_info": "0000:3b:00.1", "speed": "25000Mb/s", "link": true},
|
||||
{"name": "ens18", "bus_info": "virtio1", "speed": "Unknown!"}, {"name": "eno1", "bus_info": "0000:3b:00.0", "link": false}],
|
||||
"lldp": {"eno2": {"chassis": {"leaf-1": {}}, "port": {"id": {"value": "Ethernet3"}}}}}`
|
||||
ports, _ := portsOf(&Machine{Inventory: json.RawMessage(inv)})
|
||||
if len(ports) != 3 {
|
||||
t.Fatalf("ports: %+v", ports)
|
||||
}
|
||||
byName := map[string]Port{}
|
||||
for _, p := range ports {
|
||||
byName[p.Name] = p
|
||||
}
|
||||
if p := byName["eno2"]; p.Card != "0000:3b:00" || p.Switch != "leaf-1" || p.SwitchPort != "Ethernet3" || p.Link == nil || !*p.Link {
|
||||
t.Fatalf("eno2: %+v", p)
|
||||
}
|
||||
if p := byName["ens18"]; p.Card != "" || p.Speed != "" {
|
||||
t.Fatalf("ens18: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchPlaces(t *testing.T) {
|
||||
var devs []obj
|
||||
_ = json.Unmarshal([]byte(`[{"name": "leaf-1", "site": {"name": "dc1"}, "location": {"name": "Hall 1"}, "rack": {"name": "R01"}, "position": 42.0, "device_type": {"model": "S5248F-ON"}},
|
||||
{"name": "spare", "site": {"name": "dc1"}, "rack": null}]`), &devs)
|
||||
got := switchPlaces(devs)
|
||||
if len(got) != 1 || got[0] != (Switch{Name: "leaf-1", Building: "dc1", Room: "Hall 1", Rack: "R01", Unit: 42, Height: 1, Model: "S5248F-ON", Source: "netbox"}) {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeCables(t *testing.T) {
|
||||
ports := []Port{{Name: "eno1", Switch: "leaf-1.dc1", SwitchPort: "Ethernet3", Source: "lldp"}, {Name: "eno2"}, {Name: "eno3"}}
|
||||
got := mergeCables(ports, []Cable{
|
||||
{Port: "eno1", Switch: "leaf-1", SwitchPort: "Ethernet4", Source: "netbox"}, // NetBox thinks otherwise
|
||||
{Port: "eno2", Switch: "leaf-2", SwitchPort: "Ethernet3", Source: "netbox"},
|
||||
{Port: "eno2", Switch: "leaf-9", SwitchPort: "Ethernet9", Source: "local"}, // entered by hand: wins over NetBox
|
||||
{Port: "bmc", Switch: "oob-1", SwitchPort: "Gi1/0/7", Source: "netbox"},
|
||||
})
|
||||
if got[0].Switch != "leaf-1.dc1" || got[0].NetBoxSays != "leaf-1 Ethernet4" {
|
||||
t.Fatalf("lldp stays, netbox noted: %+v", got[0])
|
||||
}
|
||||
if got[1].Switch != "leaf-9" || got[1].Source != "local" || got[2].Switch != "" {
|
||||
t.Fatalf("recorded: %+v %+v", got[1], got[2])
|
||||
}
|
||||
if len(got) != 4 || got[3].Kind != "bmc" || got[3].Switch != "oob-1" || got[3].Source != "netbox" {
|
||||
t.Fatalf("bmc: %+v", got)
|
||||
}
|
||||
if n := mergeCables([]Port{{Name: "eno1", Switch: "a", SwitchPort: "1", Source: "lldp"}}, []Cable{{Port: "eno1", Switch: "A.example", SwitchPort: "1", Source: "netbox"}}); n[0].NetBoxSays != "" {
|
||||
t.Fatalf("the same switch by FQDN is no mismatch: %+v", n[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetBoxCables(t *testing.T) {
|
||||
nb, srv := fakeNB(t, map[string]string{
|
||||
"/api/dcim/devices/": `{"results": [{"id": 7, "name": "node7", "custom_fields": {"rollcall_machine": "m-7"}}, {"id": 8, "name": "x", "custom_fields": {"rollcall_machine": "-"}}], "next": null}`,
|
||||
"/api/dcim/interfaces/": `{"results": [
|
||||
{"name": "eno1", "device": {"id": 7}, "mgmt_only": false, "link_peers_type": "dcim.interface", "link_peers": [{"name": "Ethernet3", "device": {"name": "leaf-1"}}]},
|
||||
{"name": "iDRAC", "device": {"id": 7}, "mgmt_only": false, "link_peers_type": "dcim.interface", "link_peers": [{"name": "Gi1/0/7", "device": {"name": "oob-1"}}]},
|
||||
{"name": "eno2", "device": {"id": 7}, "link_peers_type": "dcim.frontport", "link_peers": [{"name": "1", "device": {"name": "patch-1"}}],
|
||||
"connected_endpoints_type": "dcim.interface", "connected_endpoints": [{"name": "Ethernet9", "device": {"name": "leaf-2"}}]},
|
||||
{"name": "eno3", "device": {"id": 7}, "link_peers_type": "dcim.frontport", "link_peers": [{"name": "2", "device": {"name": "patch-1"}}]}], "next": null}`,
|
||||
})
|
||||
defer srv.Close()
|
||||
got, err := nb.cables(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []Cable{{Machine: "m-7", Port: "eno1", Switch: "leaf-1", SwitchPort: "Ethernet3", Source: "netbox"}, {Machine: "m-7", Port: "bmc", Switch: "oob-1", SwitchPort: "Gi1/0/7", Source: "netbox"},
|
||||
{Machine: "m-7", Port: "eno2", Switch: "leaf-2", SwitchPort: "Ethernet9", Source: "netbox"}} // through the patch panel; eno3 ends on one
|
||||
if len(got) != 3 || got[0] != want[0] || got[1] != want[1] || got[2] != want[2] {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeNB: a NetBox answering fixed JSON per path
|
||||
func fakeNB(t *testing.T, pages map[string]string) (*netBox, *httptest.Server) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
b, ok := pages[r.URL.Path]
|
||||
if !ok {
|
||||
t.Errorf("unexpected %s", r.URL.Path)
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, b)
|
||||
}))
|
||||
return &netBox{base: srv.URL, token: "t", tag: "rollcall", field: "rollcall_machine", client: srv.Client()}, srv
|
||||
}
|
||||
@@ -0,0 +1,710 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
racks, err := a.store.RackTrust(ctx)
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
st.Racks = racks
|
||||
for _, r := range racks {
|
||||
switch st.Phase {
|
||||
case "prepared": // switch needs the new root at every rack
|
||||
if r.Bundle != st.Bundle {
|
||||
st.NotReady = append(st.NotReady, r.Name+": has not installed the bundle with the new root")
|
||||
}
|
||||
case "switched": // retire needs every rack on certificates from the new root
|
||||
if r.ClientCA != st.Active || r.ServerCA != "" && r.ServerCA != st.Active {
|
||||
st.NotReady = append(st.NotReady, r.Name+": still has a certificate from the old root")
|
||||
}
|
||||
}
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func (a *App) handleCARotation(w http.ResponseWriter, r *http.Request) {
|
||||
if a.ca == nil {
|
||||
fail(w, 409, errors.New("no CA"))
|
||||
return
|
||||
}
|
||||
st, err := a.rotation(r.Context())
|
||||
if err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, st)
|
||||
}
|
||||
|
||||
// noteRackTrust records, at registration, the root of the client certificate
|
||||
// the rack connected with and what it says about its bundle and server certificate.
|
||||
func (a *App) noteRackTrust(r *http.Request, rack string, rc RackController) {
|
||||
if a.ca == nil || r.TLS == nil {
|
||||
return
|
||||
}
|
||||
var client string
|
||||
for _, ch := range r.TLS.VerifiedChains {
|
||||
if len(ch) == 2 {
|
||||
client = fpHex(ch[1])
|
||||
}
|
||||
}
|
||||
_ = a.store.SetRackTrust(r.Context(), rack, rc.CABundleSHA256, client, rc.ServerCA)
|
||||
}
|
||||
|
||||
// serverCAFor is the root an agent behind this rack must accept: that of the
|
||||
// rack's server certificate (it serves that one as /ca.pem).
|
||||
func (a *App) serverCAFor(base string) *x509.Certificate {
|
||||
if base != a.cfg.ServerURL {
|
||||
for _, rc := range a.racksSnapshot() {
|
||||
if strings.TrimRight(rc.URL, "/") == base && rc.ServerCA != "" {
|
||||
for _, c := range a.ca.Trust {
|
||||
if fpHex(c) == rc.ServerCA {
|
||||
return c
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return a.ca.Cert
|
||||
}
|
||||
|
||||
var trustSig struct {
|
||||
sync.Mutex
|
||||
bundle, sig string
|
||||
}
|
||||
|
||||
// trustState is signed once per bundle: the rack state stays the same between polls.
|
||||
func (a *App) trustState() *rackTrustState {
|
||||
trustSig.Lock()
|
||||
defer trustSig.Unlock()
|
||||
if trustSig.bundle != string(a.ca.TrustPEM) {
|
||||
trustSig.bundle, trustSig.sig = string(a.ca.TrustPEM), a.ca.signBytes(a.ca.TrustPEM)
|
||||
}
|
||||
return &rackTrustState{Bundle: trustSig.bundle, Sig: trustSig.sig, Active: fpHex(a.ca.Cert)}
|
||||
}
|
||||
|
||||
// caRefreshFiles keep a deployed machine's roots current: the bundle comes
|
||||
// over HTTPS from the rack, checked against the Rollcall roots the machine
|
||||
// trusts now (pinned: a certificate from a public CA for the rack's name is
|
||||
// not enough), so a rotation reaches it during prepare (before the new root
|
||||
// signs) and the old root leaves it after retire. Only CA certificates are
|
||||
// taken from the bundle.
|
||||
func caRefreshFiles(tlsBase string) []seedFile {
|
||||
script := `#!/bin/sh
|
||||
# rollcall: keep the installation's roots current (written at deploy time)
|
||||
set -eu
|
||||
URL=` + shQuote(tlsBase+"/ca-bundle.pem") + `
|
||||
T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
|
||||
cat /usr/local/share/ca-certificates/rollcall-ca*.crt /etc/pki/ca-trust/source/anchors/rollcall-ca*.crt > "$T/pin" 2>/dev/null || true
|
||||
[ -s "$T/pin" ] || { echo "rollcall: no Rollcall root installed here to check $URL with" >&2; exit 1; }
|
||||
rc=0; curl -fsS --max-time 30 --cacert "$T/pin" -o "$T/bundle" "$URL" || rc=$?
|
||||
if [ $rc -eq 60 ]; then # the server's certificate is from a root not trusted here
|
||||
echo "rollcall: $URL is not trusted here: the root rotation went past prepare before this machine refreshed; install the bundle by hand" >&2; exit 1
|
||||
fi
|
||||
[ $rc -eq 0 ] || exit 0 # unreachable: the timer tries again
|
||||
awk -v d="$T" '/-----BEGIN CERTIFICATE-----/{n++} n{print > (d "/c" n)} /-----END CERTIFICATE-----/{close(d "/c" n)}' "$T/bundle"
|
||||
[ -f "$T/c1" ] || exit 0
|
||||
if command -v openssl >/dev/null 2>&1; then
|
||||
for c in "$T"/c[0-9]*; do
|
||||
openssl x509 -in "$c" -noout -text 2>/dev/null | grep -q 'CA:TRUE' || { echo "rollcall: $URL sent a certificate that is not a CA; nothing changed" >&2; exit 1; }
|
||||
done
|
||||
fi
|
||||
changed=
|
||||
for dir in /usr/local/share/ca-certificates /etc/pki/ca-trust/source/anchors; do
|
||||
[ -d "$dir" ] || continue
|
||||
i=0; mkdir -p "$T/want"; rm -f "$T"/want/*
|
||||
for c in "$T"/c[0-9]*; do
|
||||
i=$((i+1)); n=rollcall-ca.crt; [ $i -gt 1 ] && n=rollcall-ca-$i.crt
|
||||
cp "$c" "$T/want/$n"
|
||||
done
|
||||
if [ "$(cat "$dir"/rollcall-ca*.crt 2>/dev/null)" != "$(cat "$T"/want/*)" ]; then
|
||||
rm -f "$dir"/rollcall-ca*.crt
|
||||
for f in "$T"/want/*; do install -m 0644 "$f" "$dir/$(basename "$f")"; done
|
||||
changed=1
|
||||
fi
|
||||
done
|
||||
[ -n "$changed" ] || exit 0
|
||||
if command -v update-ca-trust >/dev/null 2>&1; then update-ca-trust extract; elif command -v update-ca-certificates >/dev/null 2>&1; then update-ca-certificates; fi
|
||||
echo "rollcall: trusted roots updated from $URL"
|
||||
`
|
||||
return []seedFile{
|
||||
{Path: "usr/local/sbin/rollcall-ca-refresh", Mode: "0755", Content: script},
|
||||
{Path: "etc/systemd/system/rollcall-ca-refresh.service", Mode: "0644", Content: "[Unit]\nDescription=rollcall: refresh the installation's trusted roots\nWants=network-online.target\nAfter=network-online.target\n\n[Service]\nType=oneshot\nExecStart=/usr/local/sbin/rollcall-ca-refresh\n"},
|
||||
{Path: "etc/systemd/system/rollcall-ca-refresh.timer", Mode: "0644", Content: "[Unit]\nDescription=rollcall: refresh the installation's trusted roots daily\n\n[Timer]\nOnBootSec=10min\nOnUnitActiveSec=1d\nRandomizedDelaySec=1h\n\n[Install]\nWantedBy=timers.target\n"},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- CLI
|
||||
|
||||
// rollcall ca rotate [status | prepare | switch | retire] [-force]
|
||||
func caRotateCmd(cfg *Config, args []string) error {
|
||||
sub := "status"
|
||||
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
|
||||
sub, args = args[0], args[1:]
|
||||
}
|
||||
fs := newFlags("ca rotate " + sub)
|
||||
force := fs.Bool("force", false, "go on although rack controllers are not ready (they then join again)")
|
||||
_ = fs.Parse(args)
|
||||
dir := cfg.CADir
|
||||
have := func(f string) bool { _, err := os.Stat(filepath.Join(dir, f)); return err == nil }
|
||||
status := func() (*rotationStatus, error) {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var st rotationStatus
|
||||
if err := c.do("GET", "/api/v1/ca/rotation", nil, &st); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &st, nil
|
||||
}
|
||||
ready := func() error {
|
||||
st, err := status()
|
||||
if err != nil {
|
||||
if *force {
|
||||
fmt.Fprintf(os.Stderr, "cannot ask the region (%v); -force: going on\n", err)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("cannot ask the region whether rack controllers are ready: %w (-force to go on)", err)
|
||||
}
|
||||
if len(st.NotReady) > 0 && !*force {
|
||||
return fmt.Errorf("not ready:\n %s\nwait for them (they pick it up within minutes when online) or -force (they then join again)", strings.Join(st.NotReady, "\n "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
restart := "next: restart rollcall (systemctl restart rollcall); `rollcall ca rotate` shows the progress"
|
||||
switch sub {
|
||||
case "status":
|
||||
st, err := status()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
phase := map[string]string{"": "no rotation", "prepared": "prepared (next: switch)", "switched": "switched (next: retire)"}[st.Phase]
|
||||
fmt.Printf("phase %s\nactive %s\n", phase, fpString(mustFP(st.Active)))
|
||||
if st.Next != "" {
|
||||
fmt.Printf("next %s\n", fpString(mustFP(st.Next)))
|
||||
}
|
||||
if st.Prev != "" {
|
||||
fmt.Printf("prev %s (still trusted)\n", fpString(mustFP(st.Prev)))
|
||||
}
|
||||
short := func(fp string) string {
|
||||
switch {
|
||||
case fp == "":
|
||||
return "-"
|
||||
case fp == st.Active:
|
||||
return "active"
|
||||
case fp == st.Next:
|
||||
return "next"
|
||||
case fp == st.Prev:
|
||||
return "prev"
|
||||
}
|
||||
return fp[:12]
|
||||
}
|
||||
fmt.Printf("\n%-16s %-8s %-8s %-8s %s\n", "RACK", "BUNDLE", "CLIENT", "SERVER", "LAST SEEN")
|
||||
for _, r := range st.Racks {
|
||||
b := "old"
|
||||
if r.Bundle == st.Bundle {
|
||||
b = "current"
|
||||
}
|
||||
seen := "-"
|
||||
if r.LastSeen != nil {
|
||||
seen = r.LastSeen.Local().Format("2006-01-02 15:04")
|
||||
}
|
||||
fmt.Printf("%-16s %-8s %-8s %-8s %s\n", r.Name, b, short(r.ClientCA), short(r.ServerCA), seen)
|
||||
}
|
||||
for _, n := range st.NotReady {
|
||||
fmt.Println("waiting:", n)
|
||||
}
|
||||
for _, n := range st.Warnings {
|
||||
fmt.Println("note:", n)
|
||||
}
|
||||
return nil
|
||||
case "prepare":
|
||||
if have("ca-next.pem") || have("ca-prev.pem") {
|
||||
return errors.New("a rotation is under way (rollcall ca rotate status)")
|
||||
}
|
||||
next, err := newCA("rollcall CA " + time.Now().Format("2006-01-02"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeCA(dir, "ca-next", next); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("next root %s\nthe active root still signs; rack controllers and newly deployed machines get both\n%s; then `rollcall ca rotate switch`\n", next.Fingerprint(), restart)
|
||||
return nil
|
||||
case "switch":
|
||||
if !have("ca-next.pem") {
|
||||
return errors.New("no prepared root (rollcall ca rotate prepare)")
|
||||
}
|
||||
if err := ready(); err != nil {
|
||||
return err
|
||||
}
|
||||
bak := filepath.Join(dir, "rotated-"+time.Now().Format("20060102-150405"))
|
||||
if err := os.Mkdir(bak, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
steps := [][2]string{{"ca.pem", "ca-prev.pem"}, {"ca-key.pem", filepath.Join(filepath.Base(bak), "ca-key.pem")},
|
||||
{"ca-next-key.pem", "ca-key.pem"}, {"ca-next.pem", "ca.pem"}}
|
||||
for _, s := range steps {
|
||||
if err := copyKeepOwner(filepath.Join(dir, s[0]), filepath.Join(dir, s[1])); err != nil {
|
||||
return fmt.Errorf("%s -> %s: %w (the CA directory may be half switched; the old files are in %s)", s[0], s[1], err, bak)
|
||||
}
|
||||
}
|
||||
for _, f := range []string{"ca-next.pem", "ca-next-key.pem"} {
|
||||
_ = os.Remove(filepath.Join(dir, f))
|
||||
}
|
||||
fmt.Printf("the new root signs now; the old one stays trusted (its key: %s)\nrack controllers renew their certificates by themselves; admin certificates and intermediates from the old root work until retire: issue them again\n%s; then `rollcall ca rotate retire`\n", bak, restart)
|
||||
return nil
|
||||
case "retire":
|
||||
if !have("ca-prev.pem") {
|
||||
return errors.New("no previous root to retire")
|
||||
}
|
||||
if err := ready(); err != nil {
|
||||
return err
|
||||
}
|
||||
if st, err := status(); err == nil && len(st.Warnings) > 0 {
|
||||
fmt.Printf("stops working now (intermediates from the old root too):\n %s\n", strings.Join(st.Warnings, "\n "))
|
||||
}
|
||||
bak := filepath.Join(dir, "retired-"+time.Now().Format("20060102-150405")+".pem")
|
||||
if err := os.Rename(filepath.Join(dir, "ca-prev.pem"), bak); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("the old root is no longer trusted (kept in %s)\n%s\n", bak, restart)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("ca rotate %s: unknown (status, prepare, switch, retire)", sub)
|
||||
}
|
||||
|
||||
func mustFP(h string) [32]byte {
|
||||
var out [32]byte
|
||||
b, _ := hex.DecodeString(h)
|
||||
copy(out[:], b)
|
||||
return out
|
||||
}
|
||||
|
||||
// writeCA writes a root as <name>.pem / <name>-key.pem, owned like ca.pem.
|
||||
func writeCA(dir, name string, ca *CA) error {
|
||||
kder, err := x509.MarshalECPrivateKey(ca.Key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kp, cp := filepath.Join(dir, name+"-key.pem"), filepath.Join(dir, name+".pem")
|
||||
if err := os.WriteFile(kp, pemEncode("EC PRIVATE KEY", kder), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(cp, ca.PEM, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if own, ok := fileOwner(filepath.Join(dir, "ca.pem")); ok {
|
||||
_ = os.Chown(kp, own[0], own[1])
|
||||
_ = os.Chown(cp, own[0], own[1])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyKeepOwner copies src over dst (atomically) with src's mode and owner.
|
||||
func copyKeepOwner(src, dst string) error {
|
||||
b, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st, err := os.Stat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAtomic(dst, b, st.Mode().Perm()); err != nil {
|
||||
return err
|
||||
}
|
||||
if own, ok := fileOwner(src); ok {
|
||||
_ = os.Chown(dst, own[0], own[1])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// shQuote is s as one single-quoted shell word.
|
||||
func shQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" }
|
||||
|
||||
// ---------------------------------------------------------------- rack controller
|
||||
|
||||
// rackTrust is what the rack trusts now (its ca_file, written at join and by
|
||||
// the root update unit).
|
||||
func (r *rackd) rackTrust() ([]byte, []*x509.Certificate) {
|
||||
b, err := os.ReadFile(r.cfg.CAFile)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
certs, _ := parsePEMCerts(b)
|
||||
return b, certs
|
||||
}
|
||||
|
||||
// trustReport: the hash of the rack's roots and the root of its server certificate.
|
||||
func (r *rackd) trustReport() (bundleSHA, serverCA string) {
|
||||
b, trust := r.rackTrust()
|
||||
if b == nil {
|
||||
return "", ""
|
||||
}
|
||||
sum := sha256.Sum256(b)
|
||||
r.srvMu.Lock()
|
||||
c := r.srvCert
|
||||
r.srvMu.Unlock()
|
||||
if c != nil {
|
||||
if root := rootOf(c.Leaf, trust); root != nil {
|
||||
serverCA = fpHex(root)
|
||||
}
|
||||
}
|
||||
return hex.EncodeToString(sum[:]), serverCA
|
||||
}
|
||||
|
||||
// serveCA answers /ca.pem with the root of the server certificate (agents
|
||||
// pin it by fingerprint and read one certificate) and /ca-bundle.pem with all.
|
||||
func (r *rackd) serveCA(bundle bool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, req *http.Request) {
|
||||
b, trust := r.rackTrust()
|
||||
if b == nil {
|
||||
http.Error(w, "no CA", 404)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/x-pem-file")
|
||||
if bundle || len(trust) < 2 {
|
||||
_, _ = w.Write(b)
|
||||
return
|
||||
}
|
||||
r.srvMu.Lock()
|
||||
c := r.srvCert
|
||||
r.srvMu.Unlock()
|
||||
root := trust[0]
|
||||
if c != nil {
|
||||
if o := rootOf(c.Leaf, trust); o != nil {
|
||||
root = o
|
||||
}
|
||||
}
|
||||
_, _ = w.Write(pemEncode("CERTIFICATE", root.Raw))
|
||||
}
|
||||
}
|
||||
|
||||
var rackTrustMu sync.Mutex
|
||||
var rackTrustTried = map[string]time.Time{}
|
||||
|
||||
// maybeTrust follows the region's roots: a new bundle is staged for the root
|
||||
// unit, and certificates from a root that no longer signs are renewed.
|
||||
func (r *rackd) maybeTrust(t *rackTrustState) {
|
||||
if t == nil || r.cert == nil {
|
||||
return
|
||||
}
|
||||
rackTrustMu.Lock()
|
||||
defer rackTrustMu.Unlock()
|
||||
cur, trust := r.rackTrust()
|
||||
if t.Bundle != "" && t.Bundle != string(cur) {
|
||||
sum := sha256.Sum256([]byte(t.Bundle))
|
||||
key := "bundle " + hex.EncodeToString(sum[:])
|
||||
if time.Since(rackTrustTried[key]) > time.Hour {
|
||||
rackTrustTried[key] = time.Now()
|
||||
if err := r.stageBundle(t, trust); err != nil {
|
||||
log.Printf("trust bundle: %v", err)
|
||||
} else {
|
||||
log.Printf("new trust bundle staged in %s; rollcall-rack-update installs it", r.updateDir())
|
||||
}
|
||||
}
|
||||
}
|
||||
var active *x509.Certificate
|
||||
for _, c := range trust {
|
||||
if fpHex(c) == t.Active {
|
||||
active = c
|
||||
}
|
||||
}
|
||||
if active == nil {
|
||||
return // the signing root is not trusted here yet: the bundle comes first
|
||||
}
|
||||
renewed := false
|
||||
r.certMu.Lock()
|
||||
leaf := r.cert.Leaf
|
||||
r.certMu.Unlock()
|
||||
if rootOf(leaf, trust) != active && time.Since(rackTrustTried["client"]) > 10*time.Minute {
|
||||
rackTrustTried["client"] = time.Now()
|
||||
if err := r.renew(); err != nil {
|
||||
log.Printf("certificate from the new root: %v", err)
|
||||
} else {
|
||||
renewed = true
|
||||
}
|
||||
}
|
||||
r.srvMu.Lock()
|
||||
srv := r.srvCert
|
||||
r.srvMu.Unlock()
|
||||
if srv != nil && rootOf(srv.Leaf, trust) != active && time.Since(rackTrustTried["server"]) > 10*time.Minute {
|
||||
rackTrustTried["server"] = time.Now()
|
||||
if c, err := r.newServerCert(); err != nil {
|
||||
log.Printf("server certificate from the new root: %v", err)
|
||||
} else {
|
||||
r.srvMu.Lock()
|
||||
r.srvCert = c
|
||||
r.srvMu.Unlock()
|
||||
renewed = true
|
||||
}
|
||||
}
|
||||
if renewed {
|
||||
for _, hc := range []*http.Client{r.hc, r.fast, r.big} { // new connections present the new certificate
|
||||
if tr, ok := hc.Transport.(*http.Transport); ok {
|
||||
tr.CloseIdleConnections()
|
||||
}
|
||||
}
|
||||
if err := r.register(); err != nil {
|
||||
log.Printf("register after renewal: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stageBundle leaves a verified bundle and its signature for the root unit,
|
||||
// which checks the signature again against its own copy of the roots.
|
||||
func (r *rackd) stageBundle(t *rackTrustState, trust []*x509.Certificate) error {
|
||||
if _, err := verifyBundle([]byte(t.Bundle), t.Sig, trust); err != nil {
|
||||
return err
|
||||
}
|
||||
sig, _ := base64.StdEncoding.DecodeString(t.Sig)
|
||||
dir := r.updateDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAtomic(filepath.Join(dir, "ca-bundle.pem"), []byte(t.Bundle), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAtomic(filepath.Join(dir, "ca-bundle.sig"), sig, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAtomic(filepath.Join(dir, "ca-ready"), []byte("1\n"), 0o644)
|
||||
}
|
||||
|
||||
var caWordRe = regexp.MustCompile(`\brollcall\.ca=[0-9a-fA-F]{64}\b`)
|
||||
|
||||
// pinCA points the rollcall.ca= words of a boot entry at the root fp.
|
||||
func pinCA(entry, fp string) string { return caWordRe.ReplaceAllString(entry, "rollcall.ca="+fp) }
|
||||
@@ -0,0 +1,194 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func rotTestCA(t *testing.T) (string, *Config) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(t.TempDir(), "ca")
|
||||
if _, err := loadOrCreateCA(dir, "rollcall CA"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir, &Config{CADir: dir}
|
||||
}
|
||||
|
||||
func TestCARotationPhases(t *testing.T) {
|
||||
dir, cfg := rotTestCA(t)
|
||||
old, _ := loadOrCreateCA(dir, "rollcall CA")
|
||||
if len(old.Trust) != 1 || !bytes.Equal(old.TrustPEM, old.PEM) {
|
||||
t.Fatalf("no rotation: trust %d", len(old.Trust))
|
||||
}
|
||||
leafOld, _, err := old.issue("rack:r1", nil, x509.ExtKeyUsageClientAuth)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// prepare: the old root still signs, both are trusted
|
||||
if err := caRotateCmd(cfg, []string{"prepare"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := caRotateCmd(cfg, []string{"prepare"}); err == nil {
|
||||
t.Fatal("a second prepare during a rotation")
|
||||
}
|
||||
p, _ := loadOrCreateCA(dir, "rollcall CA")
|
||||
if fpHex(p.Cert) != fpHex(old.Cert) || len(p.Trust) != 2 {
|
||||
t.Fatalf("prepared: active %s trust %d", fpHex(p.Cert)[:8], len(p.Trust))
|
||||
}
|
||||
next := p.Trust[1]
|
||||
// switch (no region to ask: -force)
|
||||
if err := caRotateCmd(cfg, []string{"switch", "-force"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := loadOrCreateCA(dir, "rollcall CA")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fpHex(s.Cert) != fpHex(next) || len(s.Trust) != 2 || fpHex(s.Trust[1]) != fpHex(old.Cert) {
|
||||
t.Fatalf("switched: active should be the prepared root, the old one trusted")
|
||||
}
|
||||
if !s.Key.PublicKey.Equal(s.Cert.PublicKey) {
|
||||
t.Fatal("the active key is not the active root's")
|
||||
}
|
||||
// a certificate from the old root still verifies, a new one comes from the new root
|
||||
lc, _ := parseCertPEM(leafOld)
|
||||
if _, err := lc.Verify(x509.VerifyOptions{Roots: s.pool(), KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}}); err != nil {
|
||||
t.Fatalf("old certificate after switch: %v", err)
|
||||
}
|
||||
nb, _, _ := s.issue("rack:r1", nil, x509.ExtKeyUsageClientAuth)
|
||||
nc, _ := parseCertPEM(nb)
|
||||
if rootOf(nc, s.Trust) != s.Cert || rootOf(lc, s.Trust) == s.Cert {
|
||||
t.Fatal("rootOf")
|
||||
}
|
||||
// the region's certificate is issued again by the new root
|
||||
if _, err := regionCert(old, dir, []string{"region.example"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pair, err := regionCert(s, dir, []string{"region.example"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rc, _ := x509.ParseCertificate(pair.Certificate[0])
|
||||
if rc.CheckSignatureFrom(s.Cert) != nil {
|
||||
t.Fatal("region certificate not from the new root after switch")
|
||||
}
|
||||
if err := caRotateCmd(cfg, []string{"switch", "-force"}); err == nil {
|
||||
t.Fatal("switch without a prepared root")
|
||||
}
|
||||
// retire: only the new root
|
||||
if err := caRotateCmd(cfg, []string{"retire", "-force"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, _ := loadOrCreateCA(dir, "rollcall CA")
|
||||
if len(r.Trust) != 1 || fpHex(r.Cert) != fpHex(next) {
|
||||
t.Fatal("retired: trust should be the new root only")
|
||||
}
|
||||
if _, err := lc.Verify(x509.VerifyOptions{Roots: r.pool(), KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}}); err == nil {
|
||||
t.Fatal("a certificate from the retired root still verifies")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrustBundleSignature(t *testing.T) {
|
||||
dir, cfg := rotTestCA(t)
|
||||
if err := caRotateCmd(cfg, []string{"prepare"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ca, _ := loadOrCreateCA(dir, "rollcall CA")
|
||||
sig := ca.signBytes(ca.TrustPEM)
|
||||
got, err := verifyBundle(ca.TrustPEM, sig, []*x509.Certificate{ca.Cert})
|
||||
if err != nil || len(got) != 2 {
|
||||
t.Fatalf("valid bundle: %v (%d)", err, len(got))
|
||||
}
|
||||
stranger, _ := newCA("someone")
|
||||
if _, err := verifyBundle(ca.TrustPEM, sig, []*x509.Certificate{stranger.Cert}); err == nil {
|
||||
t.Fatal("accepted a bundle signed by a root not trusted here")
|
||||
}
|
||||
if _, err := verifyBundle(append(append([]byte{}, ca.TrustPEM...), stranger.PEM...), sig, ca.Trust); err == nil {
|
||||
t.Fatal("accepted a bundle with a root added after signing")
|
||||
}
|
||||
leaf, _, _ := ca.issue("x", []string{"x.example"}, x509.ExtKeyUsageServerAuth)
|
||||
withLeaf := append(append([]byte{}, ca.TrustPEM...), leaf...)
|
||||
if _, err := verifyBundle(withLeaf, ca.signBytes(withLeaf), ca.Trust); err == nil || !strings.Contains(err.Error(), "not a CA") {
|
||||
t.Fatalf("a leaf in the bundle: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRackStagesBundle(t *testing.T) {
|
||||
dir, cfg := rotTestCA(t)
|
||||
ca, _ := loadOrCreateCA(dir, "rollcall CA")
|
||||
rd := t.TempDir()
|
||||
caFile := filepath.Join(rd, "ca.pem")
|
||||
_ = os.WriteFile(caFile, ca.PEM, 0o644)
|
||||
certPEM, keyPEM, _ := ca.issue("rack:r1", nil, x509.ExtKeyUsageClientAuth)
|
||||
pair, _ := tls.X509KeyPair(certPEM, keyPEM)
|
||||
pair.Leaf, _ = x509.ParseCertificate(pair.Certificate[0])
|
||||
r := &rackd{cfg: RackConfig{Name: "r1", CAFile: caFile, UpdateDir: filepath.Join(rd, "update")}, cert: &pair}
|
||||
_ = caRotateCmd(cfg, []string{"prepare"})
|
||||
ca, _ = loadOrCreateCA(dir, "rollcall CA")
|
||||
app := &App{ca: ca}
|
||||
r.maybeTrust(app.trustState())
|
||||
b, err := os.ReadFile(filepath.Join(rd, "update", "ca-bundle.pem"))
|
||||
if err != nil || !bytes.Equal(b, ca.TrustPEM) {
|
||||
t.Fatalf("bundle not staged: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(rd, "update", "ca-ready")); err != nil {
|
||||
t.Fatal("no ca-ready marker")
|
||||
}
|
||||
// the signature file is DER, for openssl dgst -verify
|
||||
sig, _ := os.ReadFile(filepath.Join(rd, "update", "ca-bundle.sig"))
|
||||
if len(sig) == 0 || sig[0] != 0x30 {
|
||||
t.Fatal("signature not DER")
|
||||
}
|
||||
// a forged bundle is not staged
|
||||
_ = os.RemoveAll(filepath.Join(rd, "update"))
|
||||
stranger, _ := newCA("someone")
|
||||
forged := append(append([]byte{}, ca.TrustPEM...), stranger.PEM...)
|
||||
rackTrustTried = map[string]time.Time{}
|
||||
r.maybeTrust(&rackTrustState{Bundle: string(forged), Sig: stranger.signBytes(forged), Active: fpHex(ca.Cert)})
|
||||
if _, err := os.Stat(filepath.Join(rd, "update", "ca-ready")); err == nil {
|
||||
t.Fatal("staged a bundle signed by a stranger")
|
||||
}
|
||||
// the rack serves one certificate as /ca.pem: agents in older images read one
|
||||
_ = os.WriteFile(caFile, ca.TrustPEM, 0o644)
|
||||
if _, trust := r.rackTrust(); len(trust) != 2 {
|
||||
t.Fatal("rack trust")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCARefreshScript(t *testing.T) {
|
||||
fs := caRefreshFiles("https://10.213.78.1")
|
||||
if len(fs) != 3 || !strings.Contains(fs[0].Content, "URL='https://10.213.78.1/ca-bundle.pem'") {
|
||||
t.Fatalf("%+v", fs[0])
|
||||
}
|
||||
// the rack is checked against the Rollcall roots installed, not the system's; only CAs are taken
|
||||
if !strings.Contains(fs[0].Content, `--cacert "$T/pin"`) || !strings.Contains(fs[0].Content, "CA:TRUE") {
|
||||
t.Fatal("refresh is not pinned to the installed roots or takes any certificate")
|
||||
}
|
||||
f := filepath.Join(t.TempDir(), "s")
|
||||
_ = os.WriteFile(f, []byte(fs[0].Content), 0o755)
|
||||
if out, err := exec.Command("sh", "-n", f).CombinedOutput(); err != nil {
|
||||
t.Fatalf("sh -n: %v %s", err, out)
|
||||
}
|
||||
if shQuote("a'b") != `'a'\''b'` {
|
||||
t.Fatal("shQuote")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinCA(t *testing.T) {
|
||||
old, nw := strings.Repeat("7e", 32), strings.Repeat("a1", 32)
|
||||
in := "LABEL deploy\n APPEND initrd=x rollcall.tls=https://10.213.78.1 rollcall.ca=" + old + " quiet\n APPEND rollcall.ca=" + old + "\n"
|
||||
got := pinCA(in, nw)
|
||||
if strings.Contains(got, old) || strings.Count(got, "rollcall.ca="+nw) != 2 || !strings.Contains(got, "rollcall.tls=https://10.213.78.1 rollcall.ca="+nw+" quiet") {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
if pinCA("APPEND rollcall.server=x", nw) != "APPEND rollcall.server=x" {
|
||||
t.Fatal("changed an entry without rollcall.ca")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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})
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package main
|
||||
|
||||
// How many rack units a machine takes, so nobody has to type it in: the
|
||||
// operator's rack_units for the model, else what the firmware says (SMBIOS
|
||||
// type 3 "Height", read at commissioning), else the model. A sled in a
|
||||
// multi-node chassis (Dell C-series, blades) takes no unit of its own: its
|
||||
// height is the chassis's, and it is marked as a node.
|
||||
//
|
||||
// The model table holds only heights checked against the vendor's or a
|
||||
// reliable listing (October 2026):
|
||||
// Dell: PowerEdge Rack Quick Reference Guide (R6xx 1U, R7xx 2U, R860 2U,
|
||||
// R940 3U, R960 4U); C6400/C6600: four sleds in 2U.
|
||||
// HPE: QuickSpecs (DL325 Gen11 1U, DL345 Gen11 2U, DL560 Gen11 2U; DL360 1U,
|
||||
// DL380 2U).
|
||||
// Lenovo: Lenovo Press (SR850 V3 2U, SR860 V3 4U, SR950 V3 8U, SR950 4U;
|
||||
// SR630 1U, SR650 2U).
|
||||
// xFusion: product pages (1288H 1U, 2288H 2U, 2488H 2U).
|
||||
// Fujitsu: datasheets (RX2530 1U, RX2540 2U), Red Hat catalog (RX4770 M7 3U).
|
||||
// Inspur/KAYTUS: heights in mm from a vendor-sourced table (NF5180M6 43 mm,
|
||||
// NF5280M6 87 mm, NF5468M6 175.5 mm).
|
||||
// GIGABYTE: R1xx 1U, R2xx 2U, G2xx 2U, G4xx 4U, from the vendor's listings.
|
||||
// Supermicro's first digit is not the height (SYS-6029 comes in 1U and 2U),
|
||||
// so Supermicro is left to SMBIOS or rack_units.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type rackSize struct {
|
||||
U int `json:"u"` // rack units, 0 = unknown
|
||||
Node bool `json:"node"` // a sled sharing a chassis of U units
|
||||
Source string `json:"source"` // config | smbios | model | ""
|
||||
}
|
||||
|
||||
type sizeRule struct {
|
||||
vendor *regexp.Regexp
|
||||
model *regexp.Regexp
|
||||
u int
|
||||
node bool
|
||||
}
|
||||
|
||||
func rx(s string) *regexp.Regexp { return regexp.MustCompile(`(?i)` + s) }
|
||||
|
||||
var sizeRules = []sizeRule{
|
||||
{rx(`dell`), rx(`\bC6[456][0-9]{2}\b`), 2, true}, // C6420, C6520, C6525, C6620, C6615 sleds
|
||||
{rx(`dell`), rx(`\bR6[0-9]{2,3}\b`), 1, false}, // R640, R650, R660, R6515, R6625
|
||||
{rx(`dell`), rx(`\bR7[0-9]{2,3}\b`), 2, false}, // R740, R750, R760, R7525, R7625
|
||||
{rx(`dell`), rx(`\bR860\b`), 2, false}, //
|
||||
{rx(`dell`), rx(`\bR940\b`), 3, false}, //
|
||||
{rx(`dell`), rx(`\bR960\b`), 4, false}, //
|
||||
{rx(`hpe?\b|hewlett`), rx(`\bDL3(25|60)\b`), 1, false}, //
|
||||
{rx(`hpe?\b|hewlett`), rx(`\bDL3(45|80)\b|\bDL560\b`), 2, false},
|
||||
{rx(`lenovo`), rx(`\bSR950 V3\b`), 8, false}, // before the plain SR950
|
||||
{rx(`lenovo`), rx(`\bSR950\b`), 4, false}, //
|
||||
{rx(`lenovo`), rx(`\bSR860 V3\b`), 4, false}, //
|
||||
{rx(`lenovo`), rx(`\bSR850 V3\b`), 2, false}, //
|
||||
{rx(`lenovo`), rx(`\bSR630\b`), 1, false}, // SR630, SR630 V2, V3
|
||||
{rx(`lenovo`), rx(`\bSR650\b`), 2, false}, //
|
||||
{rx(`xfusion|huawei`), rx(`\b1288H\b`), 1, false}, //
|
||||
{rx(`xfusion|huawei`), rx(`\b2[24]88H\b`), 2, false}, //
|
||||
{rx(`fujitsu`), rx(`\bRX2530\b`), 1, false}, //
|
||||
{rx(`fujitsu`), rx(`\bRX2540\b`), 2, false}, //
|
||||
{rx(`fujitsu`), rx(`\bRX4770 M7\b`), 3, false}, //
|
||||
{rx(`inspur|kaytus`), rx(`\bNF5180M6\b`), 1, false}, //
|
||||
{rx(`inspur|kaytus`), rx(`\bNF5280M6\b`), 2, false}, //
|
||||
{rx(`inspur|kaytus`), rx(`\bNF5468M6\b`), 4, false}, //
|
||||
{rx(`giga-?byte`), rx(`^R1[0-9]{2}-`), 1, false}, // R183-Z94-…
|
||||
{rx(`giga-?byte`), rx(`^[RG]2[0-9]{2}-`), 2, false}, // R283-…, G293-…
|
||||
{rx(`giga-?byte`), rx(`^G4[0-9]{2}-`), 4, false}, // G493-…
|
||||
}
|
||||
|
||||
// machineSize: the machine's height in rack units and where it came from.
|
||||
func machineSize(m *Machine, override map[string]int) rackSize {
|
||||
mfr, model := dmiValue(deref(m.Manufacturer)), dmiValue(deref(m.Product))
|
||||
sys := asObj(asObj(invOf(m))["system"])
|
||||
node := false
|
||||
if ct := strings.ToLower(str(sys["chassis-type"])); strings.Contains(ct, "multi-system") || strings.Contains(ct, "blade") {
|
||||
node = true
|
||||
}
|
||||
if model != "" { // "vendor model" before the bare model: both may be set, map order must not choose
|
||||
for _, want := range []string{mfr + " " + model, model} {
|
||||
for k, u := range override {
|
||||
if strings.EqualFold(k, want) {
|
||||
return rackSize{U: u, Node: node, Source: "config"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if u, ok := sys["chassis-height-u"].(float64); ok && u > 0 && u <= 60 {
|
||||
return rackSize{U: int(u), Node: node, Source: "smbios"}
|
||||
}
|
||||
for _, r := range sizeRules {
|
||||
if r.vendor.MatchString(mfr) && r.model.MatchString(model) {
|
||||
return rackSize{U: r.u, Node: node || r.node, Source: "model"}
|
||||
}
|
||||
}
|
||||
return rackSize{Node: node}
|
||||
}
|
||||
|
||||
func invOf(m *Machine) obj {
|
||||
var inv obj
|
||||
_ = json.Unmarshal(m.Inventory, &inv)
|
||||
return inv
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func sizedMachine(mfr, product string, sys map[string]any) *Machine {
|
||||
inv, _ := json.Marshal(map[string]any{"system": sys})
|
||||
return &Machine{ID: "m-1", Manufacturer: &mfr, Product: &product, Inventory: inv}
|
||||
}
|
||||
|
||||
func TestMachineSize(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
mfr, product string
|
||||
sys map[string]any
|
||||
want rackSize
|
||||
}{
|
||||
{"Dell Inc.", "PowerEdge R750", nil, rackSize{2, false, "model"}},
|
||||
{"Dell Inc.", "PowerEdge R6515", nil, rackSize{1, false, "model"}},
|
||||
{"Dell Inc.", "PowerEdge R940", nil, rackSize{3, false, "model"}},
|
||||
{"Dell Inc.", "PowerEdge C6620", nil, rackSize{2, true, "model"}},
|
||||
{"HPE", "ProLiant DL380 Gen10", nil, rackSize{2, false, "model"}},
|
||||
{"HPE", "ProLiant DL325 Gen11", nil, rackSize{1, false, "model"}},
|
||||
{"Lenovo", "ThinkSystem SR950 V3 -[7DC5CTO1WW]-", nil, rackSize{8, false, "model"}},
|
||||
{"Lenovo", "ThinkSystem SR950 -[7X12CTO1WW]-", nil, rackSize{4, false, "model"}},
|
||||
{"Lenovo", "ThinkSystem SR650 V2 -[7Z73CTO1WW]-", nil, rackSize{2, false, "model"}},
|
||||
{"XFUSION", "2288H V7", nil, rackSize{2, false, "model"}},
|
||||
{"Inspur", "NF5468M6", nil, rackSize{4, false, "model"}},
|
||||
{"GIGABYTE", "R283-Z92-AAD1-000", nil, rackSize{2, false, "model"}},
|
||||
{"GIGABYTE", "G493-SB3-AAP1-000", nil, rackSize{4, false, "model"}},
|
||||
{"Supermicro", "SYS-2029P-TRT", nil, rackSize{}}, // first digit is not the height
|
||||
{"QEMU", "Standard PC (i440FX + PIIX, 1996)", nil, rackSize{}}, // unknown
|
||||
{"Supermicro", "Super Server", map[string]any{"chassis-height-u": 2}, rackSize{2, false, "smbios"}},
|
||||
{"Dell Inc.", "PowerEdge R650", map[string]any{"chassis-height-u": 0}, rackSize{1, false, "model"}}, // 0 = unspecified
|
||||
{"Acme", "X1", map[string]any{"chassis-type": "Multi-system chassis"}, rackSize{0, true, ""}},
|
||||
} {
|
||||
if got := machineSize(sizedMachine(c.mfr, c.product, c.sys), nil); got != c.want {
|
||||
t.Errorf("%s %s: %+v, want %+v", c.mfr, c.product, got, c.want)
|
||||
}
|
||||
}
|
||||
// the operator's word wins over the firmware's, the firmware's over the table's
|
||||
m := sizedMachine("Dell Inc.", "PowerEdge R750", map[string]any{"chassis-height-u": 1})
|
||||
if got := machineSize(m, nil); got.U != 1 || got.Source != "smbios" {
|
||||
t.Errorf("smbios before the table: %+v", got)
|
||||
}
|
||||
if got := machineSize(m, map[string]int{"Dell Inc. PowerEdge R750": 3}); got.U != 3 || got.Source != "config" {
|
||||
t.Errorf("config first: %+v", got)
|
||||
}
|
||||
if typeHeight(rackSize{2, true, "model"}) != 0 || typeHeight(rackSize{}) != 1 || typeHeight(rackSize{U: 4}) != 4 {
|
||||
t.Error("typeHeight")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetBoxDeviceHeights(t *testing.T) {
|
||||
nb, f := testDCIM(t)
|
||||
tagged := []any{map[string]any{"name": "rollcall"}}
|
||||
mid := f.put("dcim/manufacturers/", map[string]any{"name": "Dell Inc.", "slug": "dell-inc"})
|
||||
r750 := f.put("dcim/device-types/", map[string]any{"manufacturer": float64(mid), "model": "PowerEdge R750", "slug": "poweredge-r750", "u_height": float64(1), "tags": tagged})
|
||||
s := func(v string) *string { return &v }
|
||||
mk := func(id, product string) *Machine {
|
||||
inv, _ := json.Marshal(obj{"network": []obj{}})
|
||||
return &Machine{ID: id, State: "deployed", NodeName: s(id), Manufacturer: s("Dell Inc."), Product: s(product), Inventory: inv,
|
||||
Location: Location{Building: "DC1", Rack: "R12", Unit: 10}}
|
||||
}
|
||||
rep := nb.syncDevices(t.Context(), DeviceSyncConfig{Site: "ev"}, []*Machine{mk("big", "PowerEdge R750"), mk("sled", "PowerEdge C6620")}, nil, nil)
|
||||
if rep.Error != "" {
|
||||
t.Fatal(rep.Error)
|
||||
}
|
||||
if f.objs["dcim/device-types/"][r750]["u_height"] != float64(2) {
|
||||
t.Fatalf("Rollcall's R750 type kept u_height %v", f.objs["dcim/device-types/"][r750]["u_height"])
|
||||
}
|
||||
sledType := f.find("dcim/device-types/", "model", "PowerEdge C6620")
|
||||
sled := f.find("dcim/devices/", "name", "sled")
|
||||
if sledType["u_height"] != float64(0) || sled["rack"] == nil || sled["position"] != nil {
|
||||
t.Fatalf("sled: type %v device %v (in the rack, no position)", sledType["u_height"], sled)
|
||||
}
|
||||
if f.find("dcim/devices/", "name", "big")["position"] != float64(10) {
|
||||
t.Fatal("a whole server keeps its position")
|
||||
}
|
||||
}
|
||||
+33
-16
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
package main
|
||||
|
||||
// Physical layout: sites with coordinates (the map), rooms and where each
|
||||
// rack stands in its room (the floor plan). With NetBox as the IPAM
|
||||
// authority, sites (name, coordinates, address), locations (rooms) and racks
|
||||
// (name, height) are read from it on every reconcile and never written back;
|
||||
// otherwise they are entered here. Rack positions are Rollcall's either way:
|
||||
// NetBox has no floor coordinates. A position is in floor tiles (600 mm): a
|
||||
// rack is one tile wide and two deep, its front facing N, E, S or W.
|
||||
//
|
||||
// A machine's building is matched to a site by name or slug, ignoring case.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Site struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug,omitempty"`
|
||||
Lat *float64 `json:"lat"`
|
||||
Lon *float64 `json:"lon"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Source string `json:"source"` // local | netbox
|
||||
}
|
||||
|
||||
type Room struct {
|
||||
Building string `json:"building"`
|
||||
Name string `json:"name"`
|
||||
Width int `json:"width"` // floor tiles; 0 = from the racks
|
||||
Depth int `json:"depth"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- store
|
||||
|
||||
func (s *Store) Sites(ctx context.Context) ([]Site, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT name, coalesce(slug,''), lat, lon, coalesce(address,''), source FROM sites ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Site{}
|
||||
for rows.Next() {
|
||||
var x Site
|
||||
if err := rows.Scan(&x.Name, &x.Slug, &x.Lat, &x.Lon, &x.Address, &x.Source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, x)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PutSite stores a site; coordinates that are nil keep the stored ones.
|
||||
func (s *Store) PutSite(ctx context.Context, x Site) error {
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO sites(name, slug, lat, lon, address, source) VALUES ($1, nullif($2,''), $3, $4, nullif($5,''), $6)
|
||||
ON CONFLICT (name) DO UPDATE SET slug=coalesce(nullif($2,''), sites.slug), lat=coalesce($3, sites.lat), lon=coalesce($4, sites.lon),
|
||||
address=coalesce(nullif($5,''), sites.address), source=$6`, x.Name, x.Slug, x.Lat, x.Lon, x.Address, orDefault(x.Source, "local"))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSite(ctx context.Context, name string) error {
|
||||
_, err := s.pool.Exec(ctx, `DELETE FROM sites WHERE name=$1`, name)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) Rooms(ctx context.Context) ([]Room, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT building, name, width, depth, source FROM rooms ORDER BY building, name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []Room{}
|
||||
for rows.Next() {
|
||||
var x Room
|
||||
if err := rows.Scan(&x.Building, &x.Name, &x.Width, &x.Depth, &x.Source); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, x)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PutRoom stores a room; a size of 0 keeps the stored one.
|
||||
func (s *Store) PutRoom(ctx context.Context, x Room) error {
|
||||
_, err := s.pool.Exec(ctx, `INSERT INTO rooms(building, name, width, depth, source) VALUES ($1,$2,$3,$4,$5)
|
||||
ON CONFLICT (building, name) DO UPDATE SET width=CASE WHEN $3>0 THEN $3 ELSE rooms.width END,
|
||||
depth=CASE WHEN $4>0 THEN $4 ELSE rooms.depth END, source=$5`, x.Building, x.Name, x.Width, x.Depth, orDefault(x.Source, "local"))
|
||||
return err
|
||||
}
|
||||
|
||||
// SetRackPosition places a rack on its room's floor (nil x/y: back to automatic).
|
||||
func (s *Store) SetRackPosition(ctx context.Context, building, room, name string, x, y *int, facing string) error {
|
||||
tag, err := s.pool.Exec(ctx, `UPDATE racks SET x=$4, y=$5, facing=nullif($6,'') WHERE building=$1 AND room=$2 AND name=$3`, building, room, name, x, y, facing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return errNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RespellRack folds rack `from` into `to`, the same rack spelled otherwise
|
||||
// (NetBox's spelling of a rack typed in by hand): its place and comment go to
|
||||
// `to` where that has none, and machines located in `from` move to `to`.
|
||||
func (s *Store) RespellRack(ctx context.Context, from, to Rack) error {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO racks(building, room, name, height, comment, x, y, facing)
|
||||
SELECT $4, $5, $6, height, comment, x, y, facing FROM racks WHERE building=$1 AND room=$2 AND name=$3
|
||||
ON CONFLICT (building, room, name) DO UPDATE SET comment=coalesce(nullif(racks.comment,''), excluded.comment),
|
||||
x=coalesce(racks.x, excluded.x), y=CASE WHEN racks.x IS NULL THEN excluded.y ELSE racks.y END,
|
||||
facing=CASE WHEN racks.x IS NULL THEN excluded.facing ELSE racks.facing END`,
|
||||
from.Building, from.Room, from.Name, to.Building, to.Room, to.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM racks WHERE building=$1 AND room=$2 AND name=$3`, from.Building, from.Room, from.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE machines SET building=nullif($4,''), room=nullif($5,''), rack=$6
|
||||
WHERE coalesce(building,'')=$1 AND coalesce(room,'')=$2 AND rack=$3`, from.Building, from.Room, from.Name, to.Building, to.Room, to.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// sameRack: one rack whatever the case of its building, room and name
|
||||
func sameRack(a, b Rack) bool {
|
||||
return strings.EqualFold(a.Building, b.Building) && strings.EqualFold(a.Room, b.Room) && strings.EqualFold(a.Name, b.Name)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- handlers
|
||||
|
||||
func (a *App) handleSites(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
xs, err := a.store.Sites(r.Context())
|
||||
if err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, xs)
|
||||
case http.MethodPost:
|
||||
var x Site
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&x); err != nil || strings.TrimSpace(x.Name) == "" {
|
||||
fail(w, 400, errors.New("name is required"))
|
||||
return
|
||||
}
|
||||
x.Name, x.Source = strings.TrimSpace(x.Name), "local"
|
||||
if (x.Lat == nil) != (x.Lon == nil) || x.Lat != nil && (*x.Lat < -90 || *x.Lat > 90 || *x.Lon < -180 || *x.Lon > 180) {
|
||||
fail(w, 400, errors.New("lat and lon go together: -90..90, -180..180"))
|
||||
return
|
||||
}
|
||||
if cur, _ := a.store.Sites(r.Context()); a.ipamIsNetBox() {
|
||||
for _, c := range cur {
|
||||
if c.Name == x.Name && c.Source == "netbox" {
|
||||
x.Source = "netbox" // coordinates set here stand until NetBox has its own
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := a.store.PutSite(r.Context(), x); err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, x)
|
||||
case http.MethodDelete:
|
||||
if err := a.store.DeleteSite(r.Context(), r.URL.Query().Get("name")); err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, obj{"deleted": r.URL.Query().Get("name")})
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) handleRooms(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
xs, err := a.store.Rooms(r.Context())
|
||||
if err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, xs)
|
||||
case http.MethodPost:
|
||||
var x Room
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&x); err != nil || strings.TrimSpace(x.Building) == "" {
|
||||
fail(w, 400, errors.New("building is required"))
|
||||
return
|
||||
}
|
||||
if x.Width < 0 || x.Depth < 0 || x.Width > 500 || x.Depth > 500 {
|
||||
fail(w, 400, errors.New("width and depth are floor tiles, 0..500"))
|
||||
return
|
||||
}
|
||||
x.Source = "local"
|
||||
if err := a.store.PutRoom(r.Context(), x); err != nil {
|
||||
fail(w, 500, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, x)
|
||||
}
|
||||
}
|
||||
|
||||
// handleRackPositions places racks: [{"building","room","name","x","y","facing"}].
|
||||
func (a *App) handleRackPositions(w http.ResponseWriter, r *http.Request) {
|
||||
var req []struct {
|
||||
Building, Room, Name string
|
||||
X, Y *int
|
||||
Facing string
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
|
||||
fail(w, 400, err)
|
||||
return
|
||||
}
|
||||
for _, p := range req {
|
||||
if p.Facing != "" && !strings.Contains("NESW", p.Facing) || len(p.Facing) > 1 {
|
||||
fail(w, 400, fmt.Errorf("%s: facing is N, E, S or W", p.Name))
|
||||
return
|
||||
}
|
||||
if (p.X == nil) != (p.Y == nil) || p.X != nil && (*p.X < 0 || *p.Y < 0 || *p.X > 500 || *p.Y > 500) {
|
||||
fail(w, 400, fmt.Errorf("%s: x and y go together, 0..500 tiles", p.Name))
|
||||
return
|
||||
}
|
||||
if err := a.store.SetRackPosition(r.Context(), p.Building, p.Room, p.Name, p.X, p.Y, p.Facing); err != nil {
|
||||
fail(w, 400, fmt.Errorf("%s: %w", p.Name, err))
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, obj{"placed": len(req)})
|
||||
}
|
||||
|
||||
func (a *App) ipamIsNetBox() bool { _, ok := a.ipam.(*netBox); return ok }
|
||||
|
||||
// ---------------------------------------------------------------- NetBox
|
||||
|
||||
// syncLayout copies NetBox's sites, locations and racks: what Rollcall shows
|
||||
// on the map and the floor plans. Nothing is deleted here: a site, room or
|
||||
// rack that left NetBox stays until removed by hand.
|
||||
func (a *App) syncLayout(ctx context.Context) error {
|
||||
nb, ok := a.ipam.(*netBox)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
sites, rooms, racks, err := nb.layout(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, x := range sites {
|
||||
if err := a.store.PutSite(ctx, x); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, x := range rooms {
|
||||
if err := a.store.PutRoom(ctx, x); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
have, _ := a.store.Racks(ctx)
|
||||
for _, x := range racks {
|
||||
for _, h := range have { // keep the comment written here
|
||||
if h.Building == x.Building && h.Room == x.Room && h.Name == x.Name {
|
||||
x.Comment = h.Comment
|
||||
}
|
||||
}
|
||||
if err := a.store.PutRack(ctx, x); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, h := range have { // the same rack typed in otherwise: NetBox's spelling wins
|
||||
if sameRack(h, x) && (h.Building != x.Building || h.Room != x.Room || h.Name != x.Name) {
|
||||
if err := a.store.RespellRack(ctx, h, x); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("layout: rack %s / %s / %s is %s / %s / %s in NetBox: merged", h.Building, h.Room, h.Name, x.Building, x.Room, x.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := a.syncSwitches(ctx, nb); err != nil {
|
||||
return fmt.Errorf("switches: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// layout reads NetBox's sites (coordinates when set), locations as rooms and racks.
|
||||
func (n *netBox) layout(ctx context.Context) ([]Site, []Room, []Rack, error) {
|
||||
ss, err := n.all(ctx, "dcim/sites/", nil)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
var sites []Site
|
||||
byID := map[int64]string{}
|
||||
for _, s := range ss {
|
||||
name := str(s["name"])
|
||||
byID[nbID(s)] = name
|
||||
x := Site{Name: name, Slug: str(s["slug"]), Address: oneLine(str(s["physical_address"])), Source: "netbox"}
|
||||
if lat, ok := s["latitude"].(float64); ok {
|
||||
if lon, ok := s["longitude"].(float64); ok {
|
||||
x.Lat, x.Lon = &lat, &lon
|
||||
}
|
||||
}
|
||||
sites = append(sites, x)
|
||||
}
|
||||
ls, err := n.all(ctx, "dcim/locations/", nil)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("locations: %w", err)
|
||||
}
|
||||
var rooms []Room
|
||||
locName := map[int64]string{}
|
||||
for _, l := range ls {
|
||||
locName[nbID(l)] = str(l["name"])
|
||||
if site := byID[nbRef(l, "site")]; site != "" {
|
||||
rooms = append(rooms, Room{Building: site, Name: str(l["name"]), Source: "netbox"})
|
||||
}
|
||||
}
|
||||
rs, err := n.all(ctx, "dcim/racks/", nil)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("racks: %w", err)
|
||||
}
|
||||
var racks []Rack
|
||||
for _, rk := range rs {
|
||||
if site := byID[nbRef(rk, "site")]; site != "" {
|
||||
racks = append(racks, Rack{Building: site, Room: locName[nbRef(rk, "location")], Name: str(rk["name"]), Height: int(num(rk["u_height"]))})
|
||||
}
|
||||
}
|
||||
return sites, rooms, racks, nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNetBoxLayout(t *testing.T) {
|
||||
nb, f := testDCIM(t) // sites Ev and DC1, location Hall A (DC1), rack R12 in both
|
||||
dc := f.find("dcim/sites/", "slug", "dc1")
|
||||
dc["latitude"], dc["longitude"], dc["physical_address"] = 41.0082, 28.9784, "Hall Street 1\nIstanbul"
|
||||
for _, r := range f.objs["dcim/racks/"] {
|
||||
r["u_height"] = float64(48)
|
||||
if r["site"] == dc["id"] {
|
||||
r["location"] = f.find("dcim/locations/", "name", "Hall A")["id"]
|
||||
}
|
||||
}
|
||||
sites, rooms, racks, err := nb.layout(t.Context())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]Site{}
|
||||
for _, s := range sites {
|
||||
got[s.Name] = s
|
||||
}
|
||||
if d := got["DC1"]; d.Lat == nil || *d.Lat != 41.0082 || *d.Lon != 28.9784 || d.Address != "Hall Street 1 · Istanbul" || d.Slug != "dc1" || d.Source != "netbox" {
|
||||
t.Fatalf("DC1: %+v", d)
|
||||
}
|
||||
if e := got["Ev"]; e.Lat != nil {
|
||||
t.Fatalf("a site without coordinates got some: %+v", e)
|
||||
}
|
||||
if fmt.Sprint(rooms) != "[{DC1 Hall A 0 0 netbox}]" {
|
||||
t.Fatalf("rooms %v", rooms)
|
||||
}
|
||||
want := map[string]string{"DC1": "Hall A", "Ev": ""}
|
||||
for _, r := range racks {
|
||||
if r.Name != "R12" || r.Height != 48 || want[r.Building] != r.Room {
|
||||
t.Fatalf("rack %+v", r)
|
||||
}
|
||||
}
|
||||
if len(racks) != 2 {
|
||||
t.Fatalf("racks %v", racks)
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,10 @@ type Rack struct {
|
||||
Height int `json:"height"`
|
||||
Comment string `json:"comment"`
|
||||
Machines int `json:"machines,omitempty"`
|
||||
// on the room's floor (layout.go): tiles from the room's corner, front facing N/E/S/W
|
||||
X *int `json:"x"`
|
||||
Y *int `json:"y"`
|
||||
Facing string `json:"facing,omitempty"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- store
|
||||
@@ -67,7 +71,7 @@ func (s *Store) SetLocation(ctx context.Context, id string, l Location) error {
|
||||
}
|
||||
|
||||
func (s *Store) Racks(ctx context.Context) ([]Rack, error) {
|
||||
rows, err := s.pool.Query(ctx, `SELECT building, room, name, height, coalesce(comment,'') FROM racks ORDER BY building, room, name`)
|
||||
rows, err := s.pool.Query(ctx, `SELECT building, room, name, height, coalesce(comment,''), x, y, coalesce(facing,'') FROM racks ORDER BY building, room, name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -75,7 +79,7 @@ func (s *Store) Racks(ctx context.Context) ([]Rack, error) {
|
||||
out := []Rack{}
|
||||
for rows.Next() {
|
||||
var r Rack
|
||||
if err := rows.Scan(&r.Building, &r.Room, &r.Name, &r.Height, &r.Comment); err != nil {
|
||||
if err := rows.Scan(&r.Building, &r.Room, &r.Name, &r.Height, &r.Comment, &r.X, &r.Y, &r.Facing); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ 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
|
||||
rollcall ca rotate [status | prepare | switch | retire] [-force] replace the root without re-joining racks
|
||||
rollcall version
|
||||
rollcall keygen
|
||||
rollcall list
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package main
|
||||
|
||||
// The NetBox web UI through Rollcall, so it opens wherever Rollcall's does
|
||||
// (a lab NetBox on a network the browser cannot reach, say). With NetBox as
|
||||
// the IPAM and its url carrying a path (NetBox's BASE_PATH:
|
||||
// "url": "https://netbox.example/netbox/"), that path on Rollcall's own
|
||||
// NetBox listener (ui_listen, default :8444: another origin than the
|
||||
// Rollcall UI) is NetBox, for admins only (the admin certificate, as for
|
||||
// the API). NetBox keeps its
|
||||
// own login and session. Its CSRF_TRUSTED_ORIGINS must list Rollcall's
|
||||
// address for its forms to work: the browser posts from there.
|
||||
//
|
||||
// ui_url, when set, is where links to NetBox point instead (NetBox reached
|
||||
// directly); the proxy stays.
|
||||
//
|
||||
// Signing in with the admin certificate: with ui_login_secret_file, the
|
||||
// proxy tells NetBox who the admin is (X-Rollcall-User: the certificate's
|
||||
// admin name) together with the shared secret (X-Rollcall-Proxy-Secret).
|
||||
// NetBox takes the user from that header (REMOTE_AUTH_HEADER), and the web
|
||||
// server in front of NetBox must drop it from any request without the
|
||||
// secret, or anyone reaching NetBox directly could claim a user. Both
|
||||
// headers coming from the browser are always dropped here. An admin with a
|
||||
// token instead of a certificate signs in to NetBox by hand.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// netboxUIPath: the path NetBox is served under here ("/netbox"), "" when there is no proxy
|
||||
func (a *App) netboxUIPath() string {
|
||||
if _, ok := a.ipam.(*netBox); !ok {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(a.cfg.IPAM.URL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
p := strings.TrimRight(u.Path, "/")
|
||||
if p == "" || strings.HasPrefix(p, "/api") || strings.HasPrefix(p, "/rollcall") || strings.HasPrefix(p, "/images") || strings.HasPrefix(p, "/os") {
|
||||
return "" // NetBox at the root would shadow Rollcall's own paths
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (c IPAMConfig) uiListen() string { return orDefault(c.UIListen, ":8444") }
|
||||
|
||||
// netboxUIHandler serves NetBox's UI on its own listener (ui_listen): its
|
||||
// own origin, so a script in a NetBox page can neither read the Rollcall
|
||||
// UI's storage (the admin token) nor call Rollcall's API as the admin. Only
|
||||
// the proxy is there; "/" leads to it.
|
||||
func (a *App) netboxUIHandler() (http.Handler, error) {
|
||||
px, err := a.netboxProxy()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, mux := a.netboxUIPath(), http.NewServeMux()
|
||||
h := a.adminAuth(px.ServeHTTP)
|
||||
for _, m := range []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"} {
|
||||
mux.HandleFunc(m+" "+p+"/", h)
|
||||
}
|
||||
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, p+"/", http.StatusFound) })
|
||||
return logging(http.NewCrossOriginProtection().Handler(mux)), nil
|
||||
}
|
||||
|
||||
func (a *App) netboxProxy() (http.Handler, error) {
|
||||
nb := a.ipam.(*netBox)
|
||||
target, _ := url.Parse(nb.base)
|
||||
origin := target.Scheme + "://" + target.Host
|
||||
secret := ""
|
||||
if f := a.cfg.IPAM.UILoginFile; f != "" {
|
||||
b, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ipam ui_login_secret_file: %w", err)
|
||||
}
|
||||
if secret = strings.TrimSpace(string(b)); len(secret) < 16 {
|
||||
return nil, fmt.Errorf("ipam ui_login_secret_file: under 16 characters")
|
||||
}
|
||||
}
|
||||
return &httputil.ReverseProxy{
|
||||
Transport: nb.client.Transport,
|
||||
Rewrite: func(pr *httputil.ProxyRequest) {
|
||||
pr.Out.URL.Scheme, pr.Out.URL.Host = target.Scheme, target.Host
|
||||
pr.Out.Host = target.Host
|
||||
pr.Out.Header.Del("Authorization") // Rollcall's credentials are not NetBox's
|
||||
for k := range pr.Out.Header { // also X-Rollcall_User: the WSGI server makes HTTP_X_ROLLCALL_USER of both
|
||||
if n := strings.ToLower(strings.ReplaceAll(k, "_", "-")); n == "x-rollcall-user" || n == "x-rollcall-proxy-secret" {
|
||||
delete(pr.Out.Header, k)
|
||||
}
|
||||
}
|
||||
if name := a.peerAdmin(pr.In); name != "" && secret != "" {
|
||||
pr.Out.Header.Set("X-Rollcall-User", name)
|
||||
pr.Out.Header.Set("X-Rollcall-Proxy-Secret", secret)
|
||||
}
|
||||
pr.SetXForwarded()
|
||||
},
|
||||
ModifyResponse: func(resp *http.Response) error {
|
||||
if loc := resp.Header.Get("Location"); strings.HasPrefix(loc, origin+"/") { // keep the browser here
|
||||
resp.Header.Set("Location", strings.TrimPrefix(loc, origin))
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNetBoxUIPath(t *testing.T) {
|
||||
for url, want := range map[string]string{"https://nb.example/netbox/": "/netbox", "https://nb.example": "", "https://nb.example/api/": "", "http://10.0.0.6/ipam/nb": "/ipam/nb"} {
|
||||
a := &App{cfg: &Config{IPAM: IPAMConfig{URL: url}}, ipam: &netBox{base: url}}
|
||||
if got := a.netboxUIPath(); got != want {
|
||||
t.Errorf("%s: %q, want %q", url, got, want)
|
||||
}
|
||||
}
|
||||
if (&App{cfg: &Config{IPAM: IPAMConfig{URL: "https://x/netbox/"}}}).netboxUIPath() != "" {
|
||||
t.Error("no NetBox IPAM, no proxy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetBoxProxy(t *testing.T) {
|
||||
var seen *http.Request
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seen = r
|
||||
if r.URL.Path == "/netbox/" {
|
||||
http.Redirect(w, r, "http://"+r.Host+"/netbox/login/?next=/netbox/", http.StatusFound) // NetBox names itself
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, "netbox page")
|
||||
}))
|
||||
defer up.Close()
|
||||
a := &App{cfg: &Config{IPAM: IPAMConfig{URL: up.URL + "/netbox/"}}, ipam: &netBox{base: up.URL + "/netbox", client: up.Client()}}
|
||||
h, err := a.netboxProxy()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "https://rollcall.example/netbox/", nil)
|
||||
req.Header.Set("Authorization", "Bearer rollcall-admin")
|
||||
req.Header.Set("X-Rollcall-User", "admin") // a browser claiming a NetBox user
|
||||
req.Header.Set("X-Rollcall-Proxy-Secret", "guessed") // and the secret
|
||||
req.Header["X-Rollcall_user"] = []string{"admin"} // spelled so the WSGI server still reads it as HTTP_X_ROLLCALL_USER
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/netbox/login/?next=/netbox/" {
|
||||
t.Fatalf("redirect: %d %q", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
if seen.Header.Get("Authorization") != "" {
|
||||
t.Fatal("Rollcall's credentials went to NetBox")
|
||||
}
|
||||
if seen.Header.Get("X-Rollcall-User") != "" || seen.Header.Get("X-Rollcall-Proxy-Secret") != "" || len(seen.Header["X-Rollcall_user"]) != 0 {
|
||||
t.Fatal("the browser's user header went to NetBox")
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest("GET", "https://rollcall.example/netbox/dcim/sites/?q=lab", nil))
|
||||
if rec.Body.String() != "netbox page" || seen.URL.RequestURI() != "/netbox/dcim/sites/?q=lab" || seen.Header.Get("X-Forwarded-Host") != "rollcall.example" {
|
||||
t.Fatalf("page: %q %s %q", rec.Body.String(), seen.URL.RequestURI(), seen.Header.Get("X-Forwarded-Host"))
|
||||
}
|
||||
}
|
||||
|
||||
// a NetBox under a BASE_PATH names its next pages with that path: it must not be doubled
|
||||
func TestNetBoxNextLinkBasePath(t *testing.T) {
|
||||
var paths []string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
paths = append(paths, r.URL.Path)
|
||||
if r.URL.Query().Get("offset") == "" {
|
||||
fmt.Fprintf(w, `{"results": [{"id": 1}], "next": "http://elsewhere%s?limit=500&offset=500"}`, r.URL.Path)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `{"results": [{"id": 2}], "next": null}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
n := &netBox{base: srv.URL + "/netbox", token: "t", client: srv.Client()}
|
||||
got, err := n.all(context.Background(), "dcim/sites/", nil)
|
||||
if err != nil || len(got) != 2 || paths[1] != "/netbox/api/dcim/sites/" {
|
||||
t.Fatalf("%v %d %v", err, len(got), paths)
|
||||
}
|
||||
}
|
||||
|
||||
// NetBox's UI is on its own listener, admins only; the Rollcall UI's routes do not carry it
|
||||
func TestNetBoxProxyRoutes(t *testing.T) {
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "nb "+r.Method+" "+r.URL.Path) }))
|
||||
defer up.Close()
|
||||
a := &App{cfg: &Config{IPAM: IPAMConfig{URL: up.URL + "/netbox/"}, AdminHTTP: "allow"}, ipam: &netBox{base: up.URL + "/netbox", client: up.Client()}}
|
||||
h, err := a.netboxUIHandler()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
a.routes().ServeHTTP(rec, httptest.NewRequest("GET", "/netbox/dcim/", nil))
|
||||
if rec.Code == http.StatusUnauthorized || strings.Contains(rec.Body.String(), "nb GET") {
|
||||
t.Fatalf("the Rollcall UI's origin serves NetBox: %d", rec.Code)
|
||||
}
|
||||
for _, m := range []string{"GET", "POST"} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(m, "/netbox/dcim/", nil))
|
||||
if rec.Code != http.StatusUnauthorized { // admins only
|
||||
t.Fatalf("%s without admin: %d %s", m, rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// with the shared secret set, a request without an admin certificate signs no one in
|
||||
func TestNetBoxProxyLoginNeedsCertificate(t *testing.T) {
|
||||
var seen *http.Request
|
||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seen = r }))
|
||||
defer up.Close()
|
||||
f := t.TempDir() + "/secret"
|
||||
if err := os.WriteFile(f, []byte("0123456789abcdef0123\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := &App{cfg: &Config{IPAM: IPAMConfig{URL: up.URL + "/netbox/", UILoginFile: f}}, ipam: &netBox{base: up.URL + "/netbox", client: up.Client()}}
|
||||
h, err := a.netboxProxy()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "https://rollcall.example/netbox/", nil))
|
||||
if seen.Header.Get("X-Rollcall-User") != "" || seen.Header.Get("X-Rollcall-Proxy-Secret") != "" {
|
||||
t.Fatal("signed in without a certificate")
|
||||
}
|
||||
if err := os.WriteFile(f, []byte("short"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.netboxProxy(); err == nil {
|
||||
t.Fatal("a short secret was taken")
|
||||
}
|
||||
}
|
||||
+25
-2
@@ -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
@@ -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
|
||||
|
||||
+38
-4
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -252,6 +251,7 @@ 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/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 +269,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 +285,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 +322,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 +449,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 +725,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 +741,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 +889,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 +898,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 != "")
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
|
||||
@@ -101,6 +101,44 @@ 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 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 +208,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,
|
||||
|
||||
+96
-22
@@ -2,6 +2,21 @@
|
||||
// Rollcall web UI: a fleet view first (filters, selection, bulk actions), machine detail second.
|
||||
const $ = (s, el = document) => el.querySelector(s);
|
||||
const $$ = (s, el = document) => Array.from(el.querySelectorAll(s));
|
||||
// nbUI: where NetBox's UI is: ui_url, or Rollcall's NetBox listener on this
|
||||
// host (another port: its own origin), "" without one
|
||||
function nbUI() {
|
||||
const ui = S.ipam && S.ipam.provider === 'netbox' && S.ipam.ui; if (!ui) return '';
|
||||
if (ui.url) return ui.url;
|
||||
if (!ui.port) return '';
|
||||
const u = new URL(location.href); u.port = ui.port; u.pathname = ui.path; u.search = ''; u.hash = '';
|
||||
return u.href.replace(/\/$/, '');
|
||||
}
|
||||
// nbLink: "open in NetBox" for a NetBox page (path and query), when NetBox is the IPAM
|
||||
function nbLink(path, label = 'NetBox ↗') {
|
||||
const ui = nbUI();
|
||||
return ui ? `<a class="nb-link" href="${esc(ui + path)}" target="_blank" rel="noopener" title="open in NetBox">${esc(label)}</a>` : '';
|
||||
}
|
||||
const nbQ = o => '?' + new URLSearchParams(Object.entries(o).filter(([, v]) => v)).toString();
|
||||
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const human = n => { n = Number(n || 0); const u = ['B', 'K', 'M', 'G', 'T', 'P']; let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return (i <= 2 ? n.toFixed(0) : n.toFixed(1)) + u[i]; };
|
||||
const ago = ts => { if (!ts) return '-'; const s = (Date.now() - new Date(ts)) / 1000; if (s < 90) return `${s | 0}s`; if (s < 5400) return `${(s / 60) | 0}m`; if (s < 172800) return `${(s / 3600) | 0}h`; return `${(s / 86400) | 0}d`; };
|
||||
@@ -14,7 +29,7 @@ let token = '';
|
||||
try { token = localStorage.getItem('rollcall.token') || ''; } catch (e) { /* storage unavailable */ }
|
||||
// ?token=… on the URL signs in once and is removed from the address bar (for bookmarks/kiosks)
|
||||
{ const u = new URL(location.href); const t = u.searchParams.get('token'); if (t) { token = t; try { localStorage.setItem('rollcall.token', t); } catch (e) { /* ignore */ } u.searchParams.delete('token'); history.replaceState(null, '', u.pathname + u.search + u.hash); } }
|
||||
const S = { machines: [], tags: [], profiles: [], os: [], jobs: [], events: [], racks: [], subnets: [], controllers: [], layouts: [], fabrics: [], ips: [], ipam: null, sel: new Set(), q: '', states: new Set(), tagf: new Set(), loc: { building: '', room: '', rack: '' }, page: 0, per: 50, sort: 'name', dir: 1, loaded: false };
|
||||
const S = { sites: [], rooms: [], machines: [], tags: [], profiles: [], os: [], jobs: [], events: [], racks: [], subnets: [], controllers: [], layouts: [], fabrics: [], ips: [], ipam: null, sel: new Set(), q: '', states: new Set(), tagf: new Set(), loc: { building: '', room: '', rack: '' }, page: 0, per: 50, sort: 'name', dir: 1, loaded: false };
|
||||
const locStr = l => { if (!l) return ''; const p = [l.building, l.room, l.rack].filter(Boolean).join(' / '); return p + (l.unit ? ` U${l.unit}` : ''); };
|
||||
let timer = null;
|
||||
|
||||
@@ -77,10 +92,12 @@ async function refresh(full) {
|
||||
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 === '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;
|
||||
@@ -175,7 +194,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 +246,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 +529,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 +616,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 +807,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 +825,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 +873,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);
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
'use strict';
|
||||
// Server fronts drawn from the model: the drive bays the model family comes
|
||||
// with (2.5" bays standing, 3.5" bays lying, in their groups), the rack ears
|
||||
// with the control panel, vents where there are no bays, in the vendor's
|
||||
// colours. No photos and no logos: the layout is what tells servers apart.
|
||||
// A family comes in several bay options (an R750 has 8 or 16 or 24 2.5" bays
|
||||
// or 12 3.5" ones); the first option with room for the machine's disks is
|
||||
// drawn. A multi-node chassis shows one bay group per sled (Dell C-series:
|
||||
// six bays each), lit in that sled's state.
|
||||
|
||||
const MM = { W: 482.6, EAR: 22, U: 44.45 };
|
||||
const FRONT_STYLE = { // body, ears, drive carriers, latches
|
||||
dell: ['#1d1f22', '#8f959b', '#3a3e44', '#9aa2aa'],
|
||||
hpe: ['#34383d', '#26292d', '#555b62', '#b0b6bc'],
|
||||
lenovo: ['#141517', '#1f2023', '#34373b', '#3d7be0'],
|
||||
smc: ['#18191b', '#a8acb1', '#383b40', '#a0a6ac'],
|
||||
xfusion: ['#26282c', '#c9ccd0', '#42464b', '#a0a6ac'],
|
||||
fujitsu: ['#222427', '#c3c6ca', '#3e4145', '#a0a6ac'],
|
||||
inspur: ['#1c1e22', '#2a2c30', '#3a3e44', '#3aa0d8'],
|
||||
gigabyte: ['#1b1c20', '#34363a', '#393c41', '#e0a030'],
|
||||
generic: ['#212428', '#3a3e43', '#3c4046', '#9aa0a6'],
|
||||
};
|
||||
const sff = (n, group) => ({ n, ff: 's', group }), lff = n => ({ n, ff: 'l' });
|
||||
const FRONT_RULES = [ // vendor, model, style, bay options
|
||||
[/dell/i, /\bC6[456]\d{2}\b/i, 'dell', [sff(24, 6)]],
|
||||
[/dell/i, /\bR6\d{2,3}\b/i, 'dell', [sff(8), sff(10)]],
|
||||
[/dell/i, /\bR7\d{2,3}\b/i, 'dell', [sff(8), lff(12), sff(16, 8), sff(24, 8)]],
|
||||
[/dell/i, /\bR(860|940|960)\b/i, 'dell', [sff(8), sff(16, 8), sff(24, 8)]],
|
||||
[/hpe?\b|hewlett/i, /\bDL3(25|60)\b/i, 'hpe', [sff(8), sff(10)]],
|
||||
[/hpe?\b|hewlett/i, /\bDL(345|380|560)\b/i, 'hpe', [sff(8, 8), lff(12), sff(24, 8)]],
|
||||
[/lenovo/i, /\bSR63\d\b/i, 'lenovo', [sff(8), sff(10)]],
|
||||
[/lenovo/i, /\bSR6[45]\d\b/i, 'lenovo', [sff(8, 8), lff(12), sff(24, 8)]],
|
||||
[/lenovo/i, /\bSR(850|860|950)\b/i, 'lenovo', [sff(8, 8), sff(24, 8)]],
|
||||
[/xfusion|huawei/i, /\b1288H\b/i, 'xfusion', [sff(8), sff(10)]],
|
||||
[/xfusion|huawei/i, /\b2[24]88H\b/i, 'xfusion', [lff(12), sff(24, 8)]],
|
||||
[/fujitsu/i, /\bRX2530\b/i, 'fujitsu', [lff(4), sff(8), sff(10)]],
|
||||
[/fujitsu/i, /\bRX2540\b/i, 'fujitsu', [lff(12), sff(24, 8)]],
|
||||
[/fujitsu/i, /\bRX4770\b/i, 'fujitsu', [sff(8), sff(16, 8)]],
|
||||
[/inspur|kaytus/i, /\bNF5180/i, 'inspur', [lff(4), sff(10)]],
|
||||
[/inspur|kaytus/i, /\bNF5280/i, 'inspur', [lff(12), sff(24, 8)]],
|
||||
[/inspur|kaytus/i, /\bNF5468/i, 'inspur', [sff(8)]],
|
||||
[/giga-?byte/i, /^R1/i, 'gigabyte', [lff(4), sff(10)]],
|
||||
[/giga-?byte/i, /^R2/i, 'gigabyte', [lff(12), sff(24, 8)]],
|
||||
[/giga-?byte/i, /^G/i, 'gigabyte', [sff(8)]],
|
||||
];
|
||||
const FRONT_BY_U = { 1: [lff(4), sff(10)], 2: [lff(12), sff(24, 8)], 3: [lff(16)], 4: [lff(24)] };
|
||||
|
||||
// frontProfile: style and bays for a block of rackBlocks (one server, or the sleds of a chassis)
|
||||
function frontProfile(b, span) {
|
||||
const m = b.ms[0], mfr = m.manufacturer || '', model = m.product || '';
|
||||
const rule = FRONT_RULES.find(r => r[0].test(mfr) && r[1].test(model));
|
||||
const style = rule ? rule[2] : /supermicro/i.test(mfr) ? 'smc' : 'generic';
|
||||
let opts = rule ? rule[3] : FRONT_BY_U[Math.min(4, span)];
|
||||
if (b.node && !rule) opts = [sff(24, Math.max(1, Math.floor(24 / b.ms.length)))];
|
||||
const disks = b.node ? Math.max(...b.ms.map(x => x.disks || 0)) * b.ms.length : (m.disks || 0);
|
||||
let bays = opts.find(o => o.n >= disks) || opts[opts.length - 1];
|
||||
if (bays.ff === 'l' && Math.ceil(bays.n / 4) * 26.1 > span * MM.U - 6) bays = sff(Math.min(24, bays.n * 2), 8); // no room for that many 3.5" rows
|
||||
return { style, bays, disks: b.node ? 0 : disks, key: `${style}|${bays.ff}${bays.n}/${bays.group || 0}|${span}` };
|
||||
}
|
||||
|
||||
// drawFront paints the front on a canvas, px wide; cls: state class of each
|
||||
// sled (one for a server). Returns the canvas and, for a chassis, the x (mm)
|
||||
// where each sled's part of the front ends.
|
||||
function drawFront(p, span, cls, colors, px = 768) {
|
||||
const H = span * MM.U - 1, k = px / MM.W, c = document.createElement('canvas');
|
||||
c.width = px; c.height = Math.round(H * k);
|
||||
const x = c.getContext('2d'); x.scale(k, k);
|
||||
const [body, ear, carrier, latch] = FRONT_STYLE[p.style];
|
||||
const rect = (fill, a, b, w, h) => { x.fillStyle = fill; x.fillRect(a, b, w, h); };
|
||||
const dots = (a, b, w, h, step) => { // perforation
|
||||
x.fillStyle = 'rgba(0,0,0,.55)';
|
||||
for (let yy = b + step / 2, row = 0; yy < b + h; yy += step * 0.87, row++) for (let xx = a + step / 2 + (row % 2) * step / 2; xx < a + w; xx += step) { x.beginPath(); x.arc(xx, yy, step * 0.3, 0, 7); x.fill(); }
|
||||
};
|
||||
const bevel = (a, b, w, h) => { x.lineWidth = 0.5; x.strokeStyle = 'rgba(255,255,255,.18)'; x.beginPath(); x.moveTo(a, b + h); x.lineTo(a, b); x.lineTo(a + w, b); x.stroke(); x.strokeStyle = 'rgba(0,0,0,.7)'; x.beginPath(); x.moveTo(a + w, b); x.lineTo(a + w, b + h); x.lineTo(a, b + h); x.stroke(); };
|
||||
const led = (a, b, r, col) => { x.fillStyle = col; x.shadowColor = col; x.shadowBlur = r * 3; x.beginPath(); x.arc(a, b, r, 0, 7); x.fill(); x.shadowBlur = 0; };
|
||||
rect(body, 0, 0, MM.W, H);
|
||||
// ears: a state strip and LED on the left, the power button on the right
|
||||
rect(ear, 0, 0, MM.EAR, H); rect(ear, MM.W - MM.EAR, 0, MM.EAR, H);
|
||||
rect(colors[cls[0]], 1, 1, 5.5, H - 2);
|
||||
if (cls.some(c => c === 'bad' || c === 'warn')) { // something to see from across the room
|
||||
const worst = colors[cls.includes('bad') ? 'bad' : 'warn'];
|
||||
x.strokeStyle = worst; x.lineWidth = 1.6; x.strokeRect(0.8, 0.8, MM.W - 1.6, H - 1.6);
|
||||
}
|
||||
led(13, Math.min(9, H / 2 - 6), 1.8, colors[cls[0]]);
|
||||
x.strokeStyle = 'rgba(0,0,0,.6)'; x.lineWidth = 0.8; x.beginPath(); x.arc(MM.W - 11, Math.min(10, H / 2), 3.2, 0, 7); x.stroke();
|
||||
led(MM.W - 11, Math.min(10, H / 2), 1, '#7fb6ff');
|
||||
rect('rgba(0,0,0,.5)', MM.W - 15, H - 9, 8, 4.5); // USB
|
||||
// bays
|
||||
const b = p.bays, inner = [MM.EAR + 2, MM.W - MM.EAR - 2], ends = [];
|
||||
const filled = i => !p.disks || i < p.disks;
|
||||
const sled = i => b.group && cls.length > 1 ? Math.min(cls.length - 1, Math.floor(i / b.group)) : 0;
|
||||
let right = inner[0];
|
||||
if (b.ff === 's') {
|
||||
const bw = 17.6, bh = Math.min(H - 5, 70), y0 = (H - bh) / 2, gap = 4;
|
||||
let xx = inner[0];
|
||||
for (let i = 0; i < b.n; i++) {
|
||||
if (i && b.group && i % b.group === 0) { ends.push(xx + gap / 2); xx += gap; }
|
||||
if (filled(i)) {
|
||||
rect(carrier, xx + 0.4, y0, bw - 0.8, bh);
|
||||
bevel(xx + 0.4, y0, bw - 0.8, bh);
|
||||
rect(latch, xx + 2, y0 + bh * 0.62, bw - 4, Math.min(3, bh * 0.08)); // the release latch
|
||||
x.fillStyle = 'rgba(0,0,0,.45)'; for (let v = y0 + 4; v < y0 + bh * 0.55; v += 2.2) x.fillRect(xx + 3, v, bw - 6, 0.8); // vents
|
||||
led(xx + 4, y0 + bh - 3, 0.9, colors[cls[sled(i)]]); led(xx + bw - 4, y0 + bh - 3, 0.9, '#2a2d31');
|
||||
} else { rect('rgba(0,0,0,.35)', xx + 0.4, y0, bw - 0.8, bh); dots(xx + 1, y0 + 1, bw - 2, bh - 2, 2.4); }
|
||||
xx += bw;
|
||||
}
|
||||
right = xx;
|
||||
} else {
|
||||
const bw = 103, bh = 26.1, cols = Math.min(4, b.n), rows = Math.ceil(b.n / 4);
|
||||
const x0 = inner[0] + (inner[1] - inner[0] - cols * bw) / 2, y0 = (H - rows * bh) / 2;
|
||||
for (let i = 0; i < b.n; i++) {
|
||||
const xx = x0 + (i % 4) * bw, yy = y0 + Math.floor(i / 4) * bh;
|
||||
if (filled(i)) {
|
||||
rect(carrier, xx + 0.5, yy + 0.5, bw - 1, bh - 1);
|
||||
bevel(xx + 0.5, yy + 0.5, bw - 1, bh - 1);
|
||||
rect(latch, xx + 3, yy + 3, 4, bh - 6);
|
||||
x.fillStyle = 'rgba(0,0,0,.45)'; for (let v = xx + 12; v < xx + bw - 14; v += 2.4) x.fillRect(v, yy + 5, 0.9, bh - 10);
|
||||
led(xx + bw - 6, yy + 6, 1, colors[cls[0]]); led(xx + bw - 6, yy + bh - 6, 1, '#2a2d31');
|
||||
} else { rect('rgba(0,0,0,.35)', xx + 0.5, yy + 0.5, bw - 1, bh - 1); dots(xx + 2, yy + 2, bw - 4, bh - 4, 2.6); }
|
||||
}
|
||||
right = b.n >= 4 ? inner[1] : x0 + cols * bw;
|
||||
}
|
||||
if (inner[1] - right > 12) dots(right + 3, 3, inner[1] - right - 3, H - 6, 3.2); // vents where there are no bays
|
||||
return { canvas: c, ends };
|
||||
}
|
||||
|
||||
// drawRear paints a server's back: PSUs at the right end, then per server
|
||||
// (or per sled of a chassis) the BMC port, USB and VGA and its network
|
||||
// cards with their ports (SFP cages from 10G up, RJ45 below), cover plates
|
||||
// where there is no card. sleds: [{ports}] from /api/v1/cabling. Returns the
|
||||
// canvas and each port's centre in mm from the top left: {sled, name, x, y}.
|
||||
function drawRear(p, span, sleds, colors, px = 768) {
|
||||
const H = span * MM.U - 1, k = px / MM.W, c = document.createElement('canvas');
|
||||
c.width = px; c.height = Math.round(H * k);
|
||||
const x = c.getContext('2d'); x.scale(k, k);
|
||||
const [body, ear] = FRONT_STYLE[p.style];
|
||||
const rect = (fill, a, b, w, h) => { x.fillStyle = fill; x.fillRect(a, b, w, h); };
|
||||
const frame = (a, b, w, h, col = 'rgba(255,255,255,.16)') => { x.lineWidth = 0.5; x.strokeStyle = col; x.strokeRect(a, b, w, h); };
|
||||
const grille = (a, b, w, h, step = 2.6) => { x.fillStyle = 'rgba(0,0,0,.6)'; for (let yy = b + 1; yy < b + h - 1; yy += step) x.fillRect(a + 1, yy, w - 2, step * 0.45); };
|
||||
const out = [];
|
||||
rect(body, 0, 0, MM.W, H);
|
||||
rect(ear, 0, 0, 6, H); rect(ear, MM.W - 6, 0, 6, H); // the rails' flanges
|
||||
// power supplies at the right end: side by side in 1U, stacked above
|
||||
const psuW = span === 1 ? 72 : 86, psuH = span === 1 ? H - 4 : Math.min(40, H / 2 - 3);
|
||||
const psus = span === 1 ? [[MM.W - 8 - 2 * psuW - 2, 2], [MM.W - 8 - psuW, 2]] : [[MM.W - 8 - psuW, H / 2 - psuH - 1], [MM.W - 8 - psuW, H / 2 + 1]];
|
||||
for (const [a, b] of psus) {
|
||||
rect('#2b2e33', a, b, psuW, psuH); frame(a, b, psuW, psuH);
|
||||
const r = Math.min(psuH, 34) / 2 - 2; x.fillStyle = 'rgba(0,0,0,.65)'; x.beginPath(); x.arc(a + psuW * 0.62, b + psuH / 2, r, 0, 7); x.fill();
|
||||
x.strokeStyle = 'rgba(255,255,255,.12)'; x.lineWidth = 0.6; for (let q = 0; q < 4; q++) { x.beginPath(); x.arc(a + psuW * 0.62, b + psuH / 2, r * (0.3 + q * 0.22), 0, 7); x.stroke(); }
|
||||
rect('#1a1c1f', a + 4, b + psuH / 2 - 4, 12, 8); // the inlet
|
||||
x.fillStyle = colors.ok; x.beginPath(); x.arc(a + psuW - 6, b + 5, 1, 0, 7); x.fill();
|
||||
}
|
||||
const left = 8, right = psus[0][0] - 3;
|
||||
const n = Math.max(1, sleds.length), sw = (right - left) / n;
|
||||
sleds.forEach((s, si) => {
|
||||
const x0 = left + si * sw;
|
||||
if (n > 1) frame(x0 + 0.5, 1, sw - 1, H - 2, 'rgba(0,0,0,.8)');
|
||||
let xx = x0 + 3;
|
||||
const yMid = Math.min(H / 2, 20);
|
||||
// BMC port, two USB, VGA
|
||||
rect('#0d0e10', xx, yMid - 5, 12, 10); frame(xx, yMid - 5, 12, 10); out.push({ sled: si, name: 'bmc', x: xx + 6, y: yMid }); xx += 15;
|
||||
rect('#0d0e10', xx, yMid - 7, 6, 6); rect('#0d0e10', xx, yMid + 1, 6, 6); xx += 9;
|
||||
if (n === 1) { rect('#13243f', xx, yMid - 4, 16, 8); xx += 20; }
|
||||
// network cards, one plate each, ports along it
|
||||
const cards = new Map();
|
||||
for (const pt of (s.ports || []).filter(pt => pt.kind !== 'bmc')) { const kk = pt.card || pt.name; if (!cards.has(kk)) cards.set(kk, []); cards.get(kk).push(pt); }
|
||||
for (const ports of cards.values()) {
|
||||
const fast = ports.some(pt => parseInt(pt.speed) >= 10000), pw = fast ? 14 : 12.5, ph = fast ? 9 : 10.5;
|
||||
const perRow = Math.max(1, Math.floor((x0 + sw - 4 - xx - 6) / (pw + 2)));
|
||||
const rows = Math.ceil(ports.length / perRow), cols = Math.min(ports.length, perRow);
|
||||
const cw = cols * (pw + 2) + 6, ch = Math.min(H - 4, rows * (ph + 3) + 6), cy = Math.max(2, yMid - ch / 2);
|
||||
if (xx + cw > x0 + sw - 2) break; // no room left on this sled
|
||||
rect('#2f3338', xx, cy, cw, ch); frame(xx, cy, cw, ch);
|
||||
ports.forEach((pt, i) => {
|
||||
const a = xx + 4 + (i % perRow) * (pw + 2), b = cy + 3 + Math.floor(i / perRow) * (ph + 3);
|
||||
rect('#08090a', a, b, pw, ph); frame(a, b, pw, ph, 'rgba(255,255,255,.25)');
|
||||
x.fillStyle = pt.link ? colors.ok : '#3a3d42'; x.fillRect(a + 1, b - 1.6, 2.4, 1.2);
|
||||
out.push({ sled: si, name: pt.name, x: a + pw / 2, y: b + ph / 2 });
|
||||
});
|
||||
xx += cw + 3;
|
||||
}
|
||||
// cover plates for the empty slots
|
||||
for (; n === 1 && xx + 18 < right; xx += 20) { rect('#2a2d31', xx, 3, 18, H - 6); grille(xx, 5, 18, H - 10); }
|
||||
});
|
||||
return { canvas: c, ports: out };
|
||||
}
|
||||
|
||||
// drawSwitch paints a switch's port side: two rows of ports (odd above, even
|
||||
// below), uplinks to the right. Returns where port number i is (mm).
|
||||
function drawSwitch(count, colors, px = 768) {
|
||||
const H = MM.U - 1, k = px / MM.W, c = document.createElement('canvas');
|
||||
c.width = px; c.height = Math.round(H * k);
|
||||
const x = c.getContext('2d'); x.scale(k, k);
|
||||
x.fillStyle = '#1f2226'; x.fillRect(0, 0, MM.W, H);
|
||||
x.fillStyle = '#3a3e43'; x.fillRect(0, 0, 18, H); x.fillRect(MM.W - 18, 0, 18, H);
|
||||
const cols = Math.ceil(count / 2), pw = Math.min(12, (MM.W - 140) / cols - 1.2), at = {};
|
||||
for (let i = 1; i <= count; i++) {
|
||||
const col = Math.floor((i - 1) / 2), a = 24 + col * (pw + 1.2) + Math.floor(col / 6) * 2, b = (i % 2 ? 9 : 24);
|
||||
x.fillStyle = '#07080a'; x.fillRect(a, b, pw, 10); x.strokeStyle = 'rgba(255,255,255,.2)'; x.lineWidth = 0.4; x.strokeRect(a, b, pw, 10);
|
||||
at[i] = { x: a + pw / 2, y: b + 5 };
|
||||
}
|
||||
for (let u = 0; u < 6; u++) { const a = MM.W - 110 + u * 15; x.fillStyle = '#07080a'; x.fillRect(a, 14, 13, 13); x.strokeStyle = 'rgba(255,255,255,.25)'; x.strokeRect(a, 14, 13, 13); at['u' + u] = { x: a + 6.5, y: 20.5 }; }
|
||||
x.fillStyle = colors.ok; x.beginPath(); x.arc(MM.W - 10, 8, 1.2, 0, 7); x.fill();
|
||||
return { canvas: c, at };
|
||||
}
|
||||
|
||||
// drawSwitchFront: the side of a switch towards the cold aisle, at its unit
|
||||
// on the front rails: its name on a label strip in the switch's colour (the
|
||||
// colour of its cables), status LEDs and fan grilles, so it reads from the front
|
||||
function drawSwitchFront(name, color, colors, px = 768) {
|
||||
const H = MM.U - 1, k = px / MM.W, c = document.createElement('canvas');
|
||||
c.width = px; c.height = Math.round(H * k);
|
||||
const x = c.getContext('2d'); x.scale(k, k);
|
||||
x.fillStyle = '#1f2226'; x.fillRect(0, 0, MM.W, H);
|
||||
x.fillStyle = '#3a3e43'; x.fillRect(0, 0, 18, H); x.fillRect(MM.W - 18, 0, 18, H);
|
||||
x.fillStyle = color; x.fillRect(22, 4, 150, H - 8); // the label strip
|
||||
x.fillStyle = '#101214'; x.font = '600 22px ui-monospace, Menlo, Consolas, monospace'; x.textBaseline = 'middle';
|
||||
const t = name.split('.')[0]; x.fillText(t.length > 14 ? t.slice(0, 13) + '…' : t, 28, H / 2 + 1, 140);
|
||||
for (let i = 0; i < 4; i++) { x.fillStyle = i ? '#2a2d31' : colors.ok; x.beginPath(); x.arc(186 + i * 7, H / 2, 1.4, 0, 7); x.fill(); }
|
||||
for (let f = 0; f < 4; f++) { // fans
|
||||
const cx = MM.W - 50 - f * 40; x.fillStyle = 'rgba(0,0,0,.6)'; x.beginPath(); x.arc(cx, H / 2, 16, 0, 7); x.fill();
|
||||
x.strokeStyle = 'rgba(255,255,255,.12)'; x.lineWidth = 0.6; for (let q = 1; q < 4; q++) { x.beginPath(); x.arc(cx, H / 2, q * 4.5, 0, 7); x.stroke(); }
|
||||
}
|
||||
return c;
|
||||
}
|
||||
@@ -12,12 +12,14 @@
|
||||
<div class="brand">Rollcall<span class="muted">bare-metal fleet</span></div>
|
||||
<a href="#/overview" data-nav="overview">Overview</a>
|
||||
<a href="#/machines" data-nav="machines">Machines <span class="count" id="n-machines"></span></a>
|
||||
<a href="#/sites" data-nav="sites">Sites</a>
|
||||
<a href="#/racks" data-nav="racks">Racks <span class="count" id="n-racks"></span></a>
|
||||
<a href="#/network" data-nav="network">Network <span class="count" id="n-net"></span></a>
|
||||
<a href="#/jobs" data-nav="jobs">Jobs <span class="count" id="n-jobs"></span></a>
|
||||
<a href="#/tags" data-nav="tags">Tags</a>
|
||||
<a href="#/profiles" data-nav="profiles">Profiles</a>
|
||||
<a href="#/images" data-nav="images">OS images</a>
|
||||
<a id="nav-netbox" href="#" target="_blank" rel="noopener" style="display:none" title="NetBox, through Rollcall: its own login">NetBox ↗</a>
|
||||
<div class="side-foot">
|
||||
<span id="status" class="muted"></span>
|
||||
<button id="logout" class="btn ghost" title="forget the admin token">Token</button>
|
||||
@@ -49,6 +51,10 @@
|
||||
|
||||
<div id="toast" hidden></div>
|
||||
<script src="topo.js"></script>
|
||||
<script src="netview.js"></script>
|
||||
<script src="sites.js"></script>
|
||||
<script src="fronts.js"></script>
|
||||
<script src="room3d.js"></script>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,205 @@
|
||||
'use strict';
|
||||
// The network page for sites of any size: problems first, filters, then a
|
||||
// VLAN x rack-controller matrix (the default once a site is large) or the
|
||||
// topology drawing (small sites, or a filtered part of a large one), and the
|
||||
// path of a machine or address that was searched for.
|
||||
|
||||
const NV = { view: '', fabric: '', purpose: '', rack: '', q: '', allIssues: false };
|
||||
try { Object.assign(NV, JSON.parse(localStorage.getItem('rollcall.netview') || '{}'), { q: '', allIssues: false }); } catch (e) { /* storage unavailable */ }
|
||||
// ?view=matrix&fabric=…&purpose=…&rack=…&q=… on the URL: a view to share or bookmark
|
||||
{ const u = new URLSearchParams(location.search); for (const k of ['view', 'fabric', 'purpose', 'rack', 'q']) if (u.has(k)) NV[k] = u.get(k); }
|
||||
function nvSave() { try { localStorage.setItem('rollcall.netview', JSON.stringify({ view: NV.view, fabric: NV.fabric, purpose: NV.purpose, rack: NV.rack })); } catch (e) { /* ignore */ } }
|
||||
|
||||
// nvFilter: what the filters keep, for topoModel and the tables
|
||||
function nvFilter() {
|
||||
const hit = nvSearch();
|
||||
const keepSub = s => (!NV.fabric || s.fabric === NV.fabric) && (!NV.purpose || s.purpose === NV.purpose) &&
|
||||
(!NV.rack || s.primary_rack === NV.rack || s.secondary_rack === NV.rack || (S.controllers.find(c => c.name === NV.rack)?.interfaces || []).some(i => (i.addrs || []).some(a => inCidr(a, s.cidr)))) &&
|
||||
(!hit || hit.subnets.has(s.cidr));
|
||||
return {
|
||||
subnet: keepSub,
|
||||
machine: m => (!NV.rack || m.rack_controller === NV.rack) && (!hit || hit.machines.has(m.id)),
|
||||
rack: name => !NV.rack || name === NV.rack,
|
||||
active: !!(NV.fabric || NV.purpose || NV.rack || hit),
|
||||
};
|
||||
}
|
||||
|
||||
// nvSearch: machines and subnets matching the search box (name, id, IP or CIDR)
|
||||
function nvSearch() {
|
||||
const q = NV.q.trim().toLowerCase();
|
||||
if (!q) return null;
|
||||
const machines = new Set(), subnets = new Set();
|
||||
for (const m of S.machines) if (nameOf(m).toLowerCase().includes(q) || m.id.toLowerCase() === q || m.mgmt_ip === q) machines.add(m.id);
|
||||
for (const x of S.ips) if (x.ip === q || (x.hostname || '').toLowerCase() === q) { if (x.machine_id) machines.add(x.machine_id); subnets.add(x.subnet); }
|
||||
for (const s of S.subnets) if (s.cidr.toLowerCase() === q || (s.name || '').toLowerCase() === q || (parseIP(q) && inCidr(q, s.cidr))) subnets.add(s.cidr);
|
||||
for (const x of S.ips) if (machines.has(x.machine_id)) subnets.add(x.subnet);
|
||||
return { machines, subnets };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- problems
|
||||
// nvIssues: what needs attention, worst first: [{sev, text, cidr?, rack?}]
|
||||
function nvIssues() {
|
||||
const out = [];
|
||||
const ctl = Object.fromEntries(S.controllers.map(c => [c.name, c]));
|
||||
const regionVer = (S.controllers.find(c => c.kind === 'embedded') || {}).version;
|
||||
const segs = topoModel().segs;
|
||||
for (const c of S.controllers) {
|
||||
if (!c.online) {
|
||||
const serves = segs.filter(g => g.racks.has(c.name));
|
||||
const alone = serves.filter(g => ![...g.racks].some(r => r !== c.name && ctl[r]?.online));
|
||||
out.push({ sev: 'bad', rack: c.name, text: `rack controller ${c.name} is offline${alone.length ? `: no DHCP/PXE on ${alone.map(segLabel).join(', ')}` : ''}${c.machines ? ` (${c.machines} machines)` : ''}` });
|
||||
} else if (regionVer && c.version && c.version !== regionVer) {
|
||||
out.push({ sev: 'warn', rack: c.name, text: `${c.name} runs ${c.version}, the region ${regionVer} (it updates itself)` });
|
||||
}
|
||||
}
|
||||
for (const g of segs) if (g.purpose === 'provisioning' && !g.dhcp) out.push({ sev: 'warn', cidr: g.subnets[0].cidr, text: `${segLabel(g)} (provisioning) has no DHCP/PXE: its machines cannot network-boot` });
|
||||
for (const s of S.subnets) {
|
||||
if (!s.size) continue;
|
||||
const pct = s.used * 100 / s.size;
|
||||
if (pct >= 80) out.push({ sev: pct >= 90 ? 'bad' : 'warn', cidr: s.cidr, text: `${s.cidr}${s.name ? ' ' + s.name : ''} is ${Math.round(pct)}% used (${s.used}/${s.size})` });
|
||||
}
|
||||
const last = S.ipam && S.ipam.last_sync;
|
||||
if (last && last.error) out.push({ sev: 'bad', text: `${S.ipam.provider} IPAM: ${last.error}` });
|
||||
if (last && last.conflicts && last.conflicts.length) out.push({ sev: 'warn', text: `${S.ipam.provider} IPAM: ${last.conflicts.length} conflict(s): ${last.conflicts.slice(0, 3).join('; ')}` });
|
||||
return out.sort((a, b) => (a.sev === 'bad' ? 0 : 1) - (b.sev === 'bad' ? 0 : 1));
|
||||
}
|
||||
const segLabel = g => `${g.fabric || 'no fabric'}/${g.vid ? 'vlan ' + g.vid : 'untagged'}`;
|
||||
|
||||
function nvIssuesHTML() {
|
||||
const is = nvIssues();
|
||||
if (!is.length) return `<div class="nv-issues ok"><span class="dot ok"></span>No network problems found: every rack controller online, provisioning VLANs served, subnets below 80%.</div>`;
|
||||
const show = NV.allIssues ? is : is.slice(0, 5);
|
||||
return `<div class="nv-issues">${show.map(i => `<div class="nv-issue ${i.sev}"${i.cidr ? ` data-ips="${esc(i.cidr)}"` : ''}${i.rack ? ` data-nvrack="${esc(i.rack)}"` : ''}><span class="dot ${i.sev}"></span>${esc(i.text)}</div>`).join('')}
|
||||
${is.length > 5 ? `<a href="#" id="nv-more">${NV.allIssues ? 'fewer' : `${is.length - 5} more`}</a>` : ''}</div>`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- matrix
|
||||
// One row per VLAN (grouped by fabric), one column per rack controller:
|
||||
// what the rack does there, then subnets, machines and how full it is.
|
||||
function nvMatrixHTML(f) {
|
||||
const { segs: drawn, racks, ms } = topoModel(f);
|
||||
if (!drawn.length) return `<div class="empty">${NV.q.trim() && !nvSearch().machines.size && !nvSearch().subnets.size ? `No machine, address or subnet matches “${esc(NV.q.trim())}”.` : 'No VLAN matches the filters.'}</div>`;
|
||||
// fabric by fabric, VLANs in order (topoModel puts DHCP VLANs first, for the drawing)
|
||||
const order = drawn.map((g, i) => i).sort((a, b) => String(drawn[a].fabric).localeCompare(String(drawn[b].fabric)) || drawn[a].vid - drawn[b].vid);
|
||||
const segs = order.map(i => drawn[i]), pos = {}; order.forEach((i, j) => { pos[i] = j; });
|
||||
for (const r of racks) r.at = r.at.map(i => pos[i]);
|
||||
for (const x of ms) x.at = x.at.map(i => pos[i]);
|
||||
const cols = racks.filter(r => r.at.length && f.rack(r.c.name));
|
||||
const perSeg = segs.map(() => ({}));
|
||||
for (const x of ms) for (const i of x.at) perSeg[i][x.m.state] = (perSeg[i][x.m.state] || 0) + 1;
|
||||
const hit = nvSearch();
|
||||
const cell = (r, i, g) => {
|
||||
const s0 = g.subnets.find(s => s.dhcp) || {};
|
||||
let sym = '', cls = '', tip = '';
|
||||
if (s0.primary_rack === r.c.name) { sym = s0.dhcp_mode === 'proxy' ? '◐' : '●'; cls = 'serve'; tip = s0.dhcp_mode === 'proxy' ? 'proxy DHCP: PXE only' : 'DHCP + PXE'; }
|
||||
else if (s0.secondary_rack === r.c.name) { sym = '●'; cls = 'serve second'; tip = 'DHCP + PXE (secondary)'; }
|
||||
else if (r.at.includes(i)) { sym = '○'; cls = 'on'; tip = 'has an address on this VLAN'; }
|
||||
if (sym && !r.c.online) { cls += ' down'; tip += ' · rack OFFLINE'; }
|
||||
return `<td class="nv-cell ${cls}" title="${esc(r.c.name + ': ' + (tip || 'not on this VLAN'))}">${sym}</td>`;
|
||||
};
|
||||
const rows = [];
|
||||
let fab = null;
|
||||
segs.forEach((g, i) => {
|
||||
if (g.fabric !== fab) { fab = g.fabric; rows.push(`<tr class="nv-fab"><td colspan="${6 + cols.length}">${esc(fab || 'no fabric')}</td></tr>`); }
|
||||
const used = g.subnets.reduce((a, s) => a + (s.used || 0), 0), size = g.subnets.reduce((a, s) => a + (s.size || 0), 0);
|
||||
const pct = Math.max(0, ...g.subnets.map(s => s.size ? s.used * 100 / s.size : 0));
|
||||
const n = Object.values(perSeg[i]).reduce((a, b) => a + b, 0);
|
||||
const hl = hit && g.subnets.some(s => hit.subnets.has(s.cidr));
|
||||
rows.push(`<tr class="nv-row${hl ? ' hl' : ''}" data-ips="${esc(g.subnets[0].cidr)}">
|
||||
<td class="mono">${g.vid || '<span class="muted">untagged</span>'}</td>
|
||||
<td>${g.subnets.map(s => `<span class="mono">${esc(s.cidr)}</span>${s.name ? ` <span class="muted">${esc(s.name)}</span>` : ''}`).join('<br>')}</td>
|
||||
<td>${esc(g.purpose)}${g.mtu && g.mtu !== 1500 ? ` <span class="pill tag">mtu ${g.mtu}</span>` : ''}</td>
|
||||
${cols.map(r => cell(r, i, g)).join('')}
|
||||
<td class="num">${n ? `${n}<div class="nv-states">${nvStateBar(perSeg[i], n)}</div>` : '<span class="muted">0</span>'}</td>
|
||||
<td><div class="bar${pct >= 90 ? ' bad' : pct >= 80 ? ' warn' : ''}" title="${used}/${size || '∞'}"><span style="width:${Math.round(pct)}%"></span></div><span class="muted">${size ? Math.round(pct) + '%' : used + ' addr'}</span></td>
|
||||
<td class="muted">${g.dhcp ? (g.subnets.some(s => s.dhcp_mode === 'proxy') ? 'proxy' : 'DHCP') : ''}</td></tr>`);
|
||||
});
|
||||
return `<div class="tw"><table class="nv-matrix"><thead><tr><th>VLAN</th><th>Subnets</th><th>Purpose</th>
|
||||
${cols.map(r => `<th class="nv-rackh${r.c.online ? '' : ' down'}" title="${esc(`${r.c.name} (${r.c.kind}) ${r.c.online ? 'online' : 'OFFLINE'}`)}"><span>${esc(r.c.name)}</span></th>`).join('')}
|
||||
<th class="num">Machines</th><th>Used</th><th></th></tr></thead><tbody>${rows.join('')}</tbody></table></div>
|
||||
<div class="legend"><span><b class="g">●</b> serves DHCP + PXE</span><span><b class="g">◐</b> proxy DHCP (PXE only)</span><span><b>○</b> has an address there</span><span><b class="r">●</b> rack offline</span><span>click a row for its addresses</span></div>`;
|
||||
}
|
||||
function nvStateBar(by, n) {
|
||||
const order = ['bad', 'warn', 'ok', 'idle'];
|
||||
const agg = {};
|
||||
for (const [st, c] of Object.entries(by)) agg[stateClass(st)] = (agg[stateClass(st)] || 0) + c;
|
||||
return order.filter(k => agg[k]).map(k => `<i class="${k}" style="width:${(agg[k] * 100 / n).toFixed(1)}%" title="${agg[k]} ${k === 'bad' ? 'failed' : k === 'warn' ? 'busy / new' : k === 'ok' ? 'deployed / ready' : 'other'}"></i>`).join('');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- path
|
||||
// The way from a machine to the region: interface, address, VLAN, the rack
|
||||
// controllers serving it, the region.
|
||||
function nvPathHTML() {
|
||||
const hit = nvSearch();
|
||||
if (!hit || hit.machines.size !== 1) return hit && hit.machines.size > 1 ? `<p class="muted">${hit.machines.size} machines match; the views show their VLANs.</p>` : '';
|
||||
const m = S.machines.find(x => x.id === [...hit.machines][0]);
|
||||
if (!m) return '';
|
||||
const ctl = Object.fromEntries(S.controllers.map(c => [c.name, c]));
|
||||
const lines = S.ips.filter(x => x.machine_id === m.id).map(x => {
|
||||
const s = S.subnets.find(y => y.cidr === x.subnet) || {};
|
||||
const serving = [s.primary_rack, s.secondary_rack].filter(Boolean);
|
||||
const rk = serving.length ? serving : [m.rack_controller].filter(Boolean);
|
||||
return `<div class="nv-path"><span class="pill tag">${esc(x.iface || '?')}</span><span class="arr">→</span><span class="mono">${esc(x.ip)}</span><span class="arr">→</span>
|
||||
<span>${esc(s.fabric || 'no fabric')} / ${s.vid ? 'vlan ' + s.vid : 'untagged'} <span class="muted">${esc(s.cidr || x.subnet)} ${esc(s.purpose || '')}</span></span>
|
||||
${rk.length ? `<span class="arr">→</span>${rk.map(r => `<span class="${ctl[r] && !ctl[r].online ? 'bad' : ''}">${serving.includes(r) ? '' : '<span class="muted">via</span> '}${esc(r)}${ctl[r] && !ctl[r].online ? ' (offline)' : ''}${s.dhcp && serving.includes(r) ? ' <span class="muted">DHCP/PXE</span>' : ''}</span>`).join(', ')}<span class="arr">→</span><span>region</span>` : ''}</div>`;
|
||||
});
|
||||
return `<div class="card wide nv-pathcard"><h3>Path of <a href="#/machine/${esc(m.id)}">${esc(nameOf(m))}</a> ${pill(m.state)}</h3>${lines.join('') || '<p class="muted">no addresses on modelled subnets</p>'}</div>`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- page parts
|
||||
function nvToolbarHTML(large) {
|
||||
const view = NV.view || (large ? 'matrix' : 'topology');
|
||||
const opt = (v, l, cur) => `<option value="${esc(v)}"${v === cur ? ' selected' : ''}>${esc(l)}</option>`;
|
||||
const fabrics = [...new Set(S.subnets.map(s => s.fabric).filter(Boolean))].sort();
|
||||
const purposes = [...new Set(S.subnets.map(s => s.purpose).filter(Boolean))].sort();
|
||||
return `<div class="nv-bar">
|
||||
<input id="nv-q" type="search" placeholder="machine, IP, subnet…" value="${esc(NV.q)}">
|
||||
<select id="nv-fabric">${opt('', 'all fabrics', NV.fabric)}${fabrics.map(x => opt(x, x, NV.fabric)).join('')}</select>
|
||||
<select id="nv-purpose">${opt('', 'all purposes', NV.purpose)}${purposes.map(x => opt(x, x, NV.purpose)).join('')}</select>
|
||||
<select id="nv-rack">${opt('', 'all rack controllers', NV.rack)}${S.controllers.map(c => opt(c.name, c.name + (c.online ? '' : ' (offline)'), NV.rack)).join('')}</select>
|
||||
${NV.fabric || NV.purpose || NV.rack || NV.q ? '<button class="btn sm ghost" id="nv-clear">clear</button>' : ''}
|
||||
<span class="right"><span class="seg-btns"><button class="btn sm${view === 'matrix' ? ' on' : ''}" data-nvview="matrix">Matrix</button><button class="btn sm${view === 'topology' ? ' on' : ''}" data-nvview="topology">Topology</button></span></span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function nvLarge() { return S.subnets.length > 12 || S.machines.length > 60 || S.controllers.length > 4; }
|
||||
|
||||
function wireNetView() {
|
||||
const again = () => { nvSave(); render(); };
|
||||
const q = $('#nv-q');
|
||||
if (q) {
|
||||
q.oninput = () => { NV.q = q.value; clearTimeout(q._t); q._t = setTimeout(() => { render(); const n = $('#nv-q'); if (n) { n.focus(); n.setSelectionRange(n.value.length, n.value.length); } }, 250); };
|
||||
}
|
||||
for (const k of ['fabric', 'purpose', 'rack']) { const el = $('#nv-' + k); if (el) el.onchange = () => { NV[k] = el.value; again(); }; }
|
||||
const c = $('#nv-clear'); if (c) c.onclick = () => { Object.assign(NV, { fabric: '', purpose: '', rack: '', q: '' }); again(); };
|
||||
$$('[data-nvview]').forEach(b => b.onclick = () => { NV.view = b.dataset.nvview; again(); });
|
||||
$$('[data-nvrack]').forEach(d => d.onclick = () => { NV.rack = d.dataset.nvrack; again(); });
|
||||
const more = $('#nv-more'); if (more) more.onclick = ev => { ev.preventDefault(); NV.allIssues = !NV.allIssues; render(); };
|
||||
}
|
||||
|
||||
// nvFabricSummaryHTML: the overview's network card on a large site, one row
|
||||
// per fabric instead of a drawing; a row opens the network page filtered to it.
|
||||
function nvFabricSummaryHTML() {
|
||||
const { segs, racks, ms } = topoModel();
|
||||
const issues = nvIssues();
|
||||
const fabs = [...new Set(segs.map(g => g.fabric || ''))].sort();
|
||||
const rows = fabs.map(fab => {
|
||||
const idx = segs.map((g, i) => (g.fabric || '') === fab ? i : -1).filter(i => i >= 0);
|
||||
const rk = racks.filter(r => r.at.some(i => idx.includes(i)));
|
||||
const down = rk.filter(r => !r.c.online).length;
|
||||
const n = ms.filter(x => x.at.some(i => idx.includes(i))).length;
|
||||
const subs = idx.flatMap(i => segs[i].subnets);
|
||||
const full = subs.filter(s => s.size).map(s => [s, s.used / s.size]).sort((a, b) => b[1] - a[1])[0];
|
||||
const cidrs = new Set(subs.map(s => s.cidr)), names = new Set(rk.map(r => r.c.name));
|
||||
const probs = issues.filter(i => (i.cidr && cidrs.has(i.cidr)) || (i.rack && names.has(i.rack))).length;
|
||||
const pct = full ? Math.round(full[1] * 100) : 0;
|
||||
return `<tr class="nv-row" data-nvfabric="${esc(fab)}"><td><b>${esc(fab || 'no fabric')}</b></td><td class="num">${idx.length}</td><td class="num">${idx.filter(i => segs[i].dhcp).length}</td>
|
||||
<td>${rk.length - down}/${rk.length}${down ? ` <span class="bad">${down} offline</span>` : ''}</td><td class="num">${n}</td>
|
||||
<td>${full ? `<span class="mono">${esc(full[0].cidr)}</span> <span class="${pct >= 90 ? 'bad' : pct >= 80 ? 'warn' : 'muted'}">${pct}%</span>` : '—'}</td>
|
||||
<td>${probs ? `<span class="${issues.some(i => i.sev === 'bad' && ((i.cidr && cidrs.has(i.cidr)) || (i.rack && names.has(i.rack)))) ? 'bad' : 'warn'}">${probs}</span>` : '<span class="ok">none</span>'}</td></tr>`;
|
||||
});
|
||||
return `<table class="nv-matrix"><thead><tr><th>Fabric</th><th class="num">VLANs</th><th class="num">with DHCP</th><th>Rack controllers</th><th class="num">Machines</th><th>Fullest subnet</th><th>Problems</th></tr></thead><tbody>${rows.join('')}</tbody></table>`;
|
||||
}
|
||||
function wireFabricSummary() {
|
||||
$$('[data-nvfabric]').forEach(r => r.onclick = () => { NV.fabric = r.dataset.nvfabric; nvSave(); nav('network'); });
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
'use strict';
|
||||
// A room in 3D (three.js, loaded on first use): the floor's 600 mm tiles,
|
||||
// racks at their places and real height, each server a block on the rack's
|
||||
// front at its units, coloured by state, the sleds of one chassis side by
|
||||
// side. The same data as the floor plan (sites.js); racks are placed in the
|
||||
// 2D editor. The renderer outlives the page's re-render: its canvas moves
|
||||
// into the new page and the scene is rebuilt only when what it shows changed.
|
||||
|
||||
const R3 = { gl: null, T: null, key: '', sig: '', orb: null, pick: [], trash: [], ctx: null, ro: null, queued: false };
|
||||
const U_M = 0.04445, TILE = 0.6, PLINTH = 0.1, TOPCAP = 0.08, RAIL = 0.483; // 19" between the rails
|
||||
|
||||
const roomView = {
|
||||
get() { try { return localStorage.getItem('rollcall.roomview') === '3d' ? '3d' : '2d'; } catch (e) { return '2d'; } },
|
||||
set(v) { try { localStorage.setItem('rollcall.roomview', v); } catch (e) { /* the choice is not kept */ } },
|
||||
};
|
||||
|
||||
// frontView: servers as their drawn fronts (fronts.js) or as blocks in their state's colour
|
||||
const frontView = {
|
||||
get() { try { return localStorage.getItem('rollcall.fronts') === 'state' ? 'state' : 'real'; } catch (e) { return 'real'; } },
|
||||
set(v) { try { localStorage.setItem('rollcall.fronts', v); } catch (e) { /* the choice is not kept */ } },
|
||||
};
|
||||
|
||||
// cablesView: the cables from the servers' ports to their switches, on or off
|
||||
const cablesView = {
|
||||
get() { try { return localStorage.getItem('rollcall.cables') !== 'off'; } catch (e) { return true; } },
|
||||
set(v) { try { localStorage.setItem('rollcall.cables', v ? 'on' : 'off'); } catch (e) { /* the choice is not kept */ } },
|
||||
};
|
||||
const SWITCH_COLORS = ['#f2a33a', '#3fbf8f', '#c470e0', '#e05d5d', '#46b8d1', '#b5c43a', '#e07fb0', '#4f8cff']; // the selection's blue last
|
||||
|
||||
function loadThree() {
|
||||
if (!loadThree.p) loadThree.p = import('./vendor/three/three.module.min.js').catch(e => { loadThree.p = null; throw e; });
|
||||
return loadThree.p;
|
||||
}
|
||||
|
||||
// room3dStop frees the renderer when no 3D room is shown
|
||||
function room3dStop() {
|
||||
if (!R3.gl) return;
|
||||
clearScene(); R3.gl.dispose(); R3.gl.forceContextLoss(); R3.gl.domElement.remove();
|
||||
if (R3.ro) R3.ro.disconnect();
|
||||
Object.assign(R3, { gl: null, key: '', sig: '', orb: null, ro: null });
|
||||
}
|
||||
|
||||
async function room3d(site, roomArg, racks) {
|
||||
const el = $('#room3d'); if (!el) return;
|
||||
let T;
|
||||
try { T = await loadThree(); } catch (e) { el.innerHTML = `<div class="empty">3D did not load: ${esc(e.message)}</div>`; return; }
|
||||
if (!el.isConnected) return; // the page moved on while loading
|
||||
if (!R3.gl) {
|
||||
try { R3.gl = new T.WebGLRenderer({ antialias: true }); } catch (e) {
|
||||
el.innerHTML = '<div class="empty">This browser has no WebGL: use the 2D floor plan.</div>'; return;
|
||||
}
|
||||
R3.T = T; R3.scene = new T.Scene(); R3.camera = new T.PerspectiveCamera(45, 1, 0.05, 1000); R3.ray = new T.Raycaster();
|
||||
R3.tip = document.createElement('div'); R3.tip.className = 'r3-tip';
|
||||
R3.gl.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
|
||||
controls(R3.gl.domElement);
|
||||
}
|
||||
R3.ctx = { site: site.name, roomArg };
|
||||
el.append(R3.gl.domElement, R3.tip); R3.tip.hidden = true;
|
||||
if (R3.ro) R3.ro.disconnect();
|
||||
R3.ro = new ResizeObserver(() => { size(); draw(); }); R3.ro.observe(el);
|
||||
const room = roomArg === '-' ? '' : roomArg, key = site.name + '|' + lc(room);
|
||||
const blocks = racks.map(r => rackBlocks(r));
|
||||
const css = getComputedStyle(document.body);
|
||||
const sig = JSON.stringify([racks.map((r, i) => [r.name, r.x, r.y, r.facing, r.height, r.auto, Object.values(blocks[i].top).map(b => [b.unit, b.h, b.known, b.ms.map(m => m.id + m.state)])]),
|
||||
racks.map((r, i) => Object.values(blocks[i].top).map(b => b.ms.map(m => [m.manufacturer, m.product, m.disks]))),
|
||||
SITE.sel, frontView.get(), cablesView.get(), S.cabling, css.getPropertyValue('--bg'), css.getPropertyValue('--accent')]);
|
||||
if (key !== R3.key) { R3.key = key; R3.orb = null; }
|
||||
if (sig !== R3.sig) {
|
||||
build(site, room, racks, blocks); R3.sig = sig;
|
||||
const sw = JSON.stringify(R3.switches); // the side panel lists them: draw it again once they are known
|
||||
if (sw !== R3.swSig) { R3.swSig = sw; render(); return; } // the switch list and the rack's elevation show them
|
||||
}
|
||||
if (!R3.orb) fit();
|
||||
size(); draw();
|
||||
}
|
||||
|
||||
function clearScene() {
|
||||
for (const x of R3.trash) x.dispose();
|
||||
R3.trash = []; R3.pick = [];
|
||||
if (R3.scene) R3.scene.clear();
|
||||
}
|
||||
|
||||
function build(site, room, racks, blocks) {
|
||||
const T = R3.T, sc = R3.scene;
|
||||
clearScene();
|
||||
const keep = x => { R3.trash.push(x); return x; };
|
||||
const css = getComputedStyle(document.body), cv = n => new T.Color(css.getPropertyValue(n).trim() || '#888888');
|
||||
sc.background = cv('--bg');
|
||||
const mats = {}; // one material per colour and look
|
||||
const mat = (k, make) => mats[k] || (mats[k] = keep(make()));
|
||||
const stateMat = (cls, known) => mat(cls + known, () => {
|
||||
const c = cv({ ok: '--ok', warn: '--warn', bad: '--danger' }[cls] || '--muted');
|
||||
return new T.MeshStandardMaterial({ color: c, emissive: c, emissiveIntensity: 0.25, roughness: 0.55 });
|
||||
});
|
||||
const lineMat = name => mat('l' + name, () => new T.LineBasicMaterial({ color: cv(name) }));
|
||||
const box = (w, h, d) => keep(new T.BoxGeometry(w, h, d));
|
||||
const real = frontView.get() === 'real', fronts = {}; // one texture per look of front
|
||||
const colorOf = Object.fromEntries(['ok', 'warn', 'bad', 'idle'].map(k => [k, css.getPropertyValue({ ok: '--ok', warn: '--warn', bad: '--danger', idle: '--muted' }[k]).trim()]));
|
||||
|
||||
sc.add(new T.HemisphereLight(0xffffff, 0x50555c, 1.7));
|
||||
const sun = new T.DirectionalLight(0xffffff, 1.3); sun.position.set(-6, 12, 8); sc.add(sun);
|
||||
|
||||
// the floor: tiles and the room's outline
|
||||
const { W, D } = roomSize(site, room, racks);
|
||||
const floor = new T.Mesh(keep(new T.PlaneGeometry(W * TILE, D * TILE)), mat('floor', () => new T.MeshStandardMaterial({ color: cv('--panel'), roughness: 1 })));
|
||||
floor.rotation.x = -Math.PI / 2; floor.position.set(W * TILE / 2, 0, D * TILE / 2); sc.add(floor);
|
||||
const grid = [];
|
||||
for (let x = 1; x < W; x++) grid.push(x * TILE, 0.001, 0, x * TILE, 0.001, D * TILE);
|
||||
for (let y = 1; y < D; y++) grid.push(0, 0.001, y * TILE, W * TILE, 0.001, y * TILE);
|
||||
const lines = (pts, m) => { const g = keep(new T.BufferGeometry()); g.setAttribute('position', new T.Float32BufferAttribute(pts, 3)); return new T.LineSegments(g, m); };
|
||||
sc.add(lines(grid, lineMat('--line')));
|
||||
const w = W * TILE, d = D * TILE;
|
||||
R3.room = { w, d, racks: racks.length ? [Math.min(...racks.map(r => r.x)) * TILE, Math.min(...racks.map(r => r.y)) * TILE,
|
||||
Math.max(...racks.map(r => r.x + footprint(r).w)) * TILE, Math.max(...racks.map(r => r.y + footprint(r).h)) * TILE] : null };
|
||||
sc.add(lines([0, 0.002, 0, w, 0.002, 0, w, 0.002, 0, w, 0.002, d, w, 0.002, d, 0, 0.002, d, 0, 0.002, d, 0, 0.002, 0], lineMat('--muted')));
|
||||
|
||||
const cab = S.cabling || { machines: [], switches: [] }, portsBy = new Map(cab.machines.map(x => [x.id, x.ports]));
|
||||
const rears = {}, rackAt = {}, portAt = new Map();
|
||||
racks.forEach((r, i) => {
|
||||
const f = footprint(r), g = new T.Group(), H = r.height || 42, h = PLINTH + H * U_M + TOPCAP, sel = SITE.sel === r.name;
|
||||
g.position.set((r.x + f.w / 2) * TILE, 0, (r.y + f.h / 2) * TILE);
|
||||
g.rotation.y = { N: 0, S: Math.PI, E: -Math.PI / 2, W: Math.PI / 2 }[r.facing]; // the front is local -z
|
||||
const st = rackState(site, r);
|
||||
// the cabinet: steel, see-through like a perforated door, so the servers inside and the cables behind show
|
||||
const body = new T.Mesh(box(TILE - 0.02, h, 2 * TILE - 0.02), mat('rack' + r.auto, () => new T.MeshStandardMaterial({ color: 0x7d8794, roughness: 0.6, metalness: 0.3, transparent: true, opacity: r.auto ? 0.18 : 0.32, depthWrite: false })));
|
||||
body.position.y = h / 2;
|
||||
body.userData = { rack: r.name, tip: `${r.name} · ${st.ms.length} machines · ${st.used}/${H}U used${r.auto ? ' · not placed yet' : ''}` };
|
||||
const edges = new T.LineSegments(keep(new T.EdgesGeometry(body.geometry)), lineMat(sel ? '--accent' : '--muted'));
|
||||
edges.position.copy(body.position);
|
||||
// the four rail posts, front and back: where a unit is, without a panel to look through
|
||||
const post = mat('post', () => new T.MeshStandardMaterial({ color: 0x4b535e, roughness: 0.6, metalness: 0.5 }));
|
||||
const posts = [-1, 1].flatMap(sx => [-1, 1].map(sz => { const p = new T.Mesh(box(0.016, H * U_M, 0.016), post); p.position.set(sx * (RAIL / 2 + 0.01), PLINTH + H * U_M / 2, sz * (TILE - 0.014)); return p; }));
|
||||
g.add(body, edges, ...posts); R3.pick.push(body);
|
||||
g.updateMatrixWorld(true);
|
||||
rackAt[lc(r.name)] = { g, H, h, r, used: blocks[i].covered };
|
||||
for (const b of Object.values(blocks[i].top)) {
|
||||
const span = Math.min(H, b.unit + b.h - 1) - b.unit + 1, hh = span * U_M - 0.004, y = PLINTH + (b.unit - 1) * U_M + 0.002 + hh / 2;
|
||||
const n = b.node ? b.ms.length : 1;
|
||||
// each server (or sled) gets the part of the front between cuts[k] and cuts[k+1], in metres
|
||||
let cuts = [...Array(n + 1).keys()].map(k => -RAIL / 2 + RAIL * k / n), front = null;
|
||||
if (real) {
|
||||
const p = frontProfile(b, span), cls = b.ms.map(m => stateClass(m.state)), key = p.key + '|' + p.disks + '|' + cls.join(',');
|
||||
front = fronts[key] || (fronts[key] = (() => {
|
||||
const f = drawFront(p, span, cls, colorOf), tex = keep(new T.CanvasTexture(f.canvas));
|
||||
tex.colorSpace = T.SRGBColorSpace; tex.anisotropy = R3.gl.capabilities.getMaxAnisotropy();
|
||||
return { tex, ends: f.ends };
|
||||
})());
|
||||
if (n > 1 && front.ends.length === n - 1) cuts = [0, ...front.ends, MM.W].map(mm => -RAIL / 2 + RAIL * mm / MM.W);
|
||||
}
|
||||
b.ms.slice(0, n).forEach((m, k) => {
|
||||
const sw = cuts[k + 1] - cuts[k];
|
||||
let look = stateMat(stateClass(m.state), b.known);
|
||||
if (front) { // the drawn front on the face towards the aisle (-z), the cabinet colour elsewhere
|
||||
let tex = front.tex;
|
||||
if (n > 1) { tex = keep(front.tex.clone()); tex.repeat.set(sw / RAIL, 1); tex.offset.set((cuts[k] + RAIL / 2) / RAIL, 0); tex.needsUpdate = true; }
|
||||
const face = keep(new T.MeshStandardMaterial({ map: tex, emissiveMap: tex, emissive: 0xffffff, emissiveIntensity: 0.3, roughness: 0.7 }));
|
||||
const side = mat('side', () => new T.MeshStandardMaterial({ color: 0x1c1e22, roughness: 0.8 }));
|
||||
look = [side, side, side, side, side, face];
|
||||
}
|
||||
const s = new T.Mesh(box(sw - (front ? 0.001 : 0.006), hh, 0.03), look);
|
||||
s.position.set(-(cuts[k] + cuts[k + 1]) / 2, y, -TILE - 0.006); // seen from the aisle, local +x is on the left
|
||||
s.userData = { rack: r.name, id: m.id, tip: `${nameOf(m)} · ${m.state} · U${b.unit}${span > 1 ? '–' + (b.unit + span - 1) : ''}${b.known ? '' : ' (height unknown)'} · ${[m.manufacturer, m.product].filter(Boolean).join(' ')}` };
|
||||
g.add(s); R3.pick.push(s);
|
||||
if (!b.known) { // height unknown (drawn as 1U): an amber outline, as "?U" in the elevation
|
||||
const o = new T.LineSegments(keep(new T.EdgesGeometry(s.geometry)), mat('unknownU', () => new T.LineBasicMaterial({ color: new T.Color(colorOf.warn) })));
|
||||
o.position.copy(s.position); o.scale.set(1.01, 1.08, 1.2); g.add(o);
|
||||
}
|
||||
});
|
||||
// the chassis between front and back: solid and dark, apart from the cabinet
|
||||
const chassis = new T.Mesh(box(RAIL - 0.004, hh - 0.002, 2 * TILE - 0.07), mat('chassis', () => new T.MeshStandardMaterial({ color: 0x202328, roughness: 0.7, metalness: 0.4 })));
|
||||
chassis.position.set(0, y, -0.006);
|
||||
g.add(chassis);
|
||||
// the back: PSUs, the BMC port and the network cards with their ports
|
||||
const p = frontProfile(b, span), sleds = b.ms.slice(0, n).map(m => ({ ports: portsBy.get(m.id) || [] }));
|
||||
const rkey = p.style + '|' + span + '|' + JSON.stringify(sleds);
|
||||
const rear = rears[rkey] || (rears[rkey] = (() => {
|
||||
const f = drawRear(p, span, sleds, colorOf), tex = keep(new T.CanvasTexture(f.canvas));
|
||||
tex.colorSpace = T.SRGBColorSpace; tex.anisotropy = R3.gl.capabilities.getMaxAnisotropy();
|
||||
const side = mat('rside', () => new T.MeshStandardMaterial({ color: 0x1c1e22, roughness: 0.8 }));
|
||||
return { look: [side, side, side, side, keep(new T.MeshStandardMaterial({ map: tex, emissiveMap: tex, emissive: 0xffffff, emissiveIntensity: 0.3, roughness: 0.7 })), side], ports: f.ports };
|
||||
})());
|
||||
const rm = new T.Mesh(box(RAIL - 0.001, hh, 0.02), rear.look);
|
||||
rm.position.set(0, y, TILE - 0.004);
|
||||
rm.userData = { rack: r.name, id: n === 1 ? b.ms[0].id : '', tip: `${b.ms.map(nameOf).join(', ')} · the back · ${sleds.reduce((a, x) => a + x.ports.length, 0)} network ports` };
|
||||
g.add(rm); R3.pick.push(rm);
|
||||
for (const pt of rear.ports) {
|
||||
const m = b.ms[pt.sled]; if (!m) continue;
|
||||
portAt.set(m.id + '|' + pt.name, { rack: lc(r.name), lx: -RAIL / 2 + RAIL * pt.x / MM.W, ly: y + hh / 2 - hh * pt.y / (span * MM.U - 1) });
|
||||
}
|
||||
}
|
||||
if (sel) { // the selected rack's footprint, in the accent colour
|
||||
const p = new T.Mesh(keep(new T.PlaneGeometry(TILE + 0.1, 2 * TILE + 0.1)), mat('selp', () => new T.MeshBasicMaterial({ color: cv('--accent'), transparent: true, opacity: 0.35 })));
|
||||
p.rotation.x = -Math.PI / 2; p.position.y = 0.003; g.add(p);
|
||||
}
|
||||
const label = labelSprite(r.name, css.getPropertyValue(sel ? '--accent' : '--fg').trim(), css.getPropertyValue('--panel').trim());
|
||||
label.position.y = h + 0.22; g.add(label);
|
||||
sc.add(g);
|
||||
});
|
||||
R3.switches = placeSwitches(site, room, cab, rackAt, portAt);
|
||||
for (const sw of R3.switches) if (sw.at) drawSwitchIn(sw, rackAt[sw.at.rack], keep, mat, colorOf);
|
||||
if (cablesView.get()) cables(cab, rackAt, portAt, keep, mat);
|
||||
}
|
||||
|
||||
// placeSwitches: where each switch the room's machines lead to stands. Its
|
||||
// stored place (NetBox, or set by hand) when that is a rack of this room;
|
||||
// else, when it has none, the rack with most of its links, at the highest
|
||||
// free unit (on top of the rack when none is free): an estimate. A switch
|
||||
// placed in another room is reached by cables leaving through the ceiling.
|
||||
function placeSwitches(site, room, cab, rackAt, portAt) {
|
||||
const links = {}; // switch -> rack -> links
|
||||
for (const mp of cab.machines) for (const pt of mp.ports) {
|
||||
const at = pt.switch && portAt.get(mp.id + '|' + pt.name); if (!at) continue;
|
||||
(links[pt.switch] = links[pt.switch] || {})[at.rack] = (links[pt.switch][at.rack] || 0) + 1;
|
||||
}
|
||||
const taken = {}; // rack -> units switches took, the placed ones first so estimates go around them
|
||||
for (const s of cab.switches) if (links[s.name] && s.rack && s.unit && rackAt[lc(s.rack)] && sameRoom(s.room || '', room)) (taken[lc(s.rack)] = taken[lc(s.rack)] || []).push(s.unit);
|
||||
return cab.switches.filter(s => links[s.name]).map((s, i) => {
|
||||
const out = { ...s, color: SWITCH_COLORS[i % SWITCH_COLORS.length], here: Object.values(links[s.name]).reduce((a, b) => a + b, 0) };
|
||||
const inRoom = s.rack && (!s.building || inSite(site, s.building)) && sameRoom(s.room || '', room) && rackAt[lc(s.rack)];
|
||||
if (s.rack && !inRoom) { out.away = [s.building, s.room, s.rack].filter(Boolean).join(' / '); return out; }
|
||||
const rack = inRoom ? lc(s.rack) : Object.entries(links[s.name]).sort((a, b) => b[1] - a[1])[0][0];
|
||||
const ra = rackAt[rack]; let unit = inRoom ? s.unit : 0;
|
||||
if (!unit) { for (let u = ra.H; u >= 1; u--) if (!ra.used.has(u) && !(taken[rack] || []).includes(u)) { unit = u; break; } }
|
||||
if (!inRoom || !s.unit) (taken[rack] = taken[rack] || []).push(unit);
|
||||
out.at = { rack, unit: unit || ra.H + 1 + (taken[rack].filter(u => !u).length - 1), estimated: !inRoom || !s.unit };
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
// switchPort: where a switch's port named like "ethernet1/1/12" or "Gi1/0/24" is on its drawing
|
||||
function switchPortNo(name) { const m = /(\d+)\D*$/.exec(name || ''); return m ? Number(m[1]) : 0; }
|
||||
|
||||
function drawSwitchIn(sw, ra, keep, mat, colorOf) {
|
||||
const T = R3.T, css = getComputedStyle(document.body);
|
||||
const max = Math.max(48, ...((S.cabling || {}).machines || []).flatMap(mp => mp.ports.filter(p => p.switch === sw.name).map(p => switchPortNo(p.switch_port))));
|
||||
const count = Math.min(128, Math.ceil(max / 2) * 2), d = drawSwitch(count, colorOf), tex = keep(new T.CanvasTexture(d.canvas));
|
||||
tex.colorSpace = T.SRGBColorSpace; tex.anisotropy = R3.gl.capabilities.getMaxAnisotropy();
|
||||
const side = mat('swside', () => new T.MeshStandardMaterial({ color: 0x15171a, roughness: 0.7 }));
|
||||
const top = sw.at.unit > ra.H, y = top ? ra.h + U_M / 2 + 0.005 : PLINTH + (sw.at.unit - 1) * U_M + U_M / 2;
|
||||
const look = [side, side, side, side, keep(new T.MeshStandardMaterial({ map: tex, emissiveMap: tex, emissive: 0xffffff, emissiveIntensity: 0.35, roughness: 0.6 })), side];
|
||||
const m = new T.Mesh(keep(new T.BoxGeometry(RAIL - 0.001, U_M - 0.004, 0.45)), look);
|
||||
m.position.set(0, y, TILE + 0.006 - 0.225);
|
||||
if (SITE.sel && lc(SITE.sel) === lc(ra.r.name)) { // names on the selected rack only: switches sit a unit apart, labels would pile up
|
||||
const tag = labelSprite(sw.name.split('.')[0], sw.color, css.getPropertyValue('--panel').trim());
|
||||
tag.scale.set(0.5, 0.125, 1); tag.position.set((sw.at.unit % 2 ? 1 : -1) * 0.42, y, TILE + 0.1); ra.g.add(tag);
|
||||
}
|
||||
const edge = new T.LineSegments(keep(new T.EdgesGeometry(m.geometry)), mat('swl' + sw.color, () => new T.LineBasicMaterial({ color: new T.Color(sw.color) })));
|
||||
edge.position.copy(m.position);
|
||||
m.userData = { rack: ra.r.name, tip: `${sw.name}${sw.role === 'oob' ? ' (out-of-band)' : ''}${sw.model ? ' · ' + sw.model : ''} · ${sw.here} cables here · ${sw.at.estimated ? 'place estimated: ' + (sw.source ? 'no unit' : 'not in NetBox') + ', set it in the panel' : 'U' + sw.at.unit + ' (' + sw.source + ')'}` };
|
||||
// its front at its unit on the front rails, named, so the switch reads from the cold aisle too
|
||||
const ftex = keep(new T.CanvasTexture(drawSwitchFront(sw.name, sw.color, colorOf))); ftex.colorSpace = T.SRGBColorSpace; ftex.anisotropy = R3.gl.capabilities.getMaxAnisotropy();
|
||||
const fm = keep(new T.MeshStandardMaterial({ map: ftex, emissiveMap: ftex, emissive: 0xffffff, emissiveIntensity: 0.35, roughness: 0.6 }));
|
||||
const front = new T.Mesh(keep(new T.BoxGeometry(RAIL - 0.001, U_M - 0.004, 0.03)), [side, side, side, side, side, fm]);
|
||||
front.position.set(0, y, -TILE - 0.006); front.userData = m.userData;
|
||||
ra.g.add(m, edge, front); R3.pick.push(m, front);
|
||||
sw.y = y;
|
||||
sw.port = name => { const n = switchPortNo(name), p = d.at[n] || d.at['u' + (n % 6)]; return { lx: -RAIL / 2 + RAIL * p.x / MM.W, ly: y + (U_M - 0.004) / 2 - (U_M - 0.004) * p.y / (MM.U - 1) }; };
|
||||
}
|
||||
|
||||
// cables, drawn like busbars so they read from across the room: per rack
|
||||
// and switch, one bus up the back of the rack (thicker with more cables),
|
||||
// a short stub from each port into it, a comb under the switch with a tail
|
||||
// into each switch port; to a switch in another rack the bus crosses over
|
||||
// the tops as a tray, to one in another room it rises to the ceiling. One
|
||||
// colour per switch; a port without link has a grey stub; with a rack
|
||||
// selected, the other racks' buses fade. All of it is a handful of
|
||||
// instanced meshes (thousands of stubs would otherwise be thousands of draws).
|
||||
function cables(cab, rackAt, portAt, keep, mat) {
|
||||
const T = R3.T, byName = Object.fromEntries(R3.switches.map(s => [s.name, s]));
|
||||
const W = (ra, x, y, z) => ra.g.localToWorld(new T.Vector3(x, y, z));
|
||||
const look = (col, dim) => mat('bus' + col + dim, () => new T.MeshStandardMaterial({ color: new T.Color(col), emissive: new T.Color(col), emissiveIntensity: 0.4, roughness: 0.45, metalness: 0.2, transparent: dim, opacity: dim ? 0.12 : 1 }));
|
||||
const bins = new Map(), dummy = new T.Object3D();
|
||||
const bar = (a, b, t, m, data) => { // a square bar from a to b (world), t thick, overrunning the joints
|
||||
const len = a.distanceTo(b); if (len < 1e-4) return;
|
||||
dummy.position.copy(a).add(b).multiplyScalar(0.5); dummy.lookAt(b); dummy.scale.set(t, t, len + t); dummy.updateMatrix();
|
||||
if (!bins.has(m)) bins.set(m, { m, mats: [], data: [] });
|
||||
bins.get(m).mats.push(dummy.matrix.clone()); bins.get(m).data.push(data);
|
||||
};
|
||||
// the cables of each rack to each switch
|
||||
const groups = new Map();
|
||||
for (const mp of cab.machines) for (const pt of mp.ports) {
|
||||
const a = pt.switch && portAt.get(mp.id + '|' + pt.name), sw = byName[pt.switch]; if (!a || !sw) continue;
|
||||
const k = a.rack + '|' + sw.name;
|
||||
if (!groups.has(k)) groups.set(k, { ra: rackAt[a.rack], sw, ports: [] });
|
||||
groups.get(k).ports.push({ mp, pt, a });
|
||||
}
|
||||
// lanes behind each rack: its switches alternate left and right, further back each pair
|
||||
const lanes = {};
|
||||
const laneOf = (ra, sw) => {
|
||||
const k = lc(ra.r.name), names = lanes[k] || (lanes[k] = [...new Set([...groups.values()].filter(g => g.ra === ra).map(g => g.sw.name)
|
||||
.concat(R3.switches.filter(s => s.at && s.at.rack === k).map(s => s.name)))].sort());
|
||||
const i = Math.max(0, names.indexOf(sw.name));
|
||||
return { x: (i % 2 ? 1 : -1) * 0.255, z: TILE + 0.06 + 0.04 * Math.floor(i / 2) };
|
||||
};
|
||||
const combs = new Map(); // switch -> the ports reaching it, for its comb
|
||||
for (const g of groups.values()) {
|
||||
const { ra, sw, ports } = g, L = laneOf(ra, sw), n = ports.length;
|
||||
const t = Math.min(0.045, 0.018 + 0.0035 * Math.sqrt(n));
|
||||
const dim = SITE.sel && lc(SITE.sel) !== lc(ra.r.name) && !(sw.at && lc(SITE.sel) === sw.at.rack);
|
||||
const busM = look(sw.color, dim), busData = dim ? null : { rack: ra.r.name, tip: `${n} cable${n > 1 ? 's' : ''} from ${ra.r.name} to ${sw.name}${sw.role === 'oob' ? ' (OOB)' : ''}${sw.away ? ' in ' + sw.away : ''}` };
|
||||
// stubs: out of the back of each port, sideways into the bus
|
||||
for (const p of ports) {
|
||||
const m0 = S.machines.find(x => x.id === p.mp.id), pt = p.pt;
|
||||
const data = dim ? null : { rack: ra.r.name, id: p.mp.id, tip: `${m0 ? nameOf(m0) : p.mp.id} ${pt.kind === 'bmc' ? 'BMC' : pt.name}${pt.speed ? ' ' + pt.speed : ''} → ${pt.switch}${sw.role === 'oob' ? ' (OOB)' : ''} ${pt.switch_port}${pt.vlan ? ' · VLAN ' + pt.vlan : ''}${pt.link === false ? ' · no link' : ''}${sw.away ? ' (in ' + sw.away + ')' : ''} · ${pt.source === 'lldp' ? 'seen by LLDP' : pt.source === 'netbox' ? 'NetBox cable' : 'entered by hand'}${pt.netbox_says ? ' · NetBox says ' + pt.netbox_says : ''}` };
|
||||
const sm = look(pt.link === false ? '#8a8f96' : sw.color, dim);
|
||||
bar(W(ra, p.a.lx, p.a.ly, TILE + 0.008), W(ra, p.a.lx, p.a.ly, L.z), 0.007, sm, data);
|
||||
bar(W(ra, p.a.lx, p.a.ly, L.z), W(ra, L.x, p.a.ly, L.z), 0.007, sm, data);
|
||||
if (sw.at) { const c = combs.get(sw) || []; c.push({ pt, dim, data }); combs.set(sw, c); }
|
||||
}
|
||||
const y0 = Math.min(...ports.map(p => p.a.ly));
|
||||
const rb = sw.at && rackAt[sw.at.rack];
|
||||
if (rb === ra) { // up the back to the comb under the switch
|
||||
bar(W(ra, L.x, y0, L.z), W(ra, L.x, sw.y - U_M / 2 - 0.02, L.z), t, busM, busData);
|
||||
continue;
|
||||
}
|
||||
const yT = Math.max(ra.h, rb ? rb.h : 0) + 0.14 + 0.03 * (R3.switches.indexOf(sw) % 5); // the tray over the tops
|
||||
bar(W(ra, L.x, y0, L.z), W(ra, L.x, yT, L.z), t, busM, busData);
|
||||
if (!rb) { bar(W(ra, L.x, yT, L.z), W(ra, L.x, 2.9, L.z), t, busM, busData); continue; } // another room: through the ceiling
|
||||
const LB = laneOf(rb, sw);
|
||||
bar(W(ra, L.x, yT, L.z), W(rb, LB.x, yT, LB.z), t, busM, busData);
|
||||
bar(W(rb, LB.x, yT, LB.z), W(rb, LB.x, sw.y - U_M / 2 - 0.02, LB.z), t, busM, busData);
|
||||
}
|
||||
// combs: under each switch from its lane to its farthest port, a tail up into each port
|
||||
for (const [sw, list] of combs) {
|
||||
const rb = rackAt[sw.at.rack], L = laneOf(rb, sw), yc = sw.y - U_M / 2 - 0.02;
|
||||
const xs = list.map(c => sw.port(c.pt.switch_port).lx), far = L.x < 0 ? Math.max(...xs) : Math.min(...xs);
|
||||
const dim = list.every(c => c.dim), t = Math.min(0.035, 0.014 + 0.003 * Math.sqrt(list.length));
|
||||
bar(W(rb, L.x, yc, L.z), W(rb, far, yc, L.z), t, look(sw.color, dim), dim ? null : { rack: rb.r.name, tip: `${sw.name}: ${list.length} cables here` });
|
||||
for (const c of list) {
|
||||
const p = sw.port(c.pt.switch_port), m = look(c.pt.link === false ? '#8a8f96' : sw.color, c.dim);
|
||||
bar(W(rb, p.lx, yc, L.z), W(rb, p.lx, p.ly, L.z), 0.006, m, c.data);
|
||||
bar(W(rb, p.lx, p.ly, L.z), W(rb, p.lx, p.ly, TILE + 0.012), 0.006, m, c.data);
|
||||
}
|
||||
}
|
||||
const geo = keep(new T.BoxGeometry(1, 1, 1));
|
||||
for (const b of bins.values()) {
|
||||
const im = new T.InstancedMesh(geo, b.m, b.mats.length);
|
||||
b.mats.forEach((mx, i) => im.setMatrixAt(i, mx));
|
||||
im.userData = { items: b.data }; R3.scene.add(im);
|
||||
if (b.data.some(Boolean)) R3.pick.push(im);
|
||||
}
|
||||
}
|
||||
|
||||
// labelSprite: a rack's name on a pill, drawn over everything so a rack in
|
||||
// front never hides it
|
||||
function labelSprite(text, color, bg) {
|
||||
const T = R3.T, c = document.createElement('canvas'); c.width = 256; c.height = 64;
|
||||
const x = c.getContext('2d'), t = text.length > 12 ? '…' + text.slice(-11) : text;
|
||||
x.font = '600 34px ui-monospace, Menlo, Consolas, monospace';
|
||||
const w = Math.min(248, x.measureText(t).width + 28);
|
||||
x.globalAlpha = 0.85; x.fillStyle = bg; x.beginPath(); x.roundRect(128 - w / 2, 8, w, 48, 12); x.fill();
|
||||
x.globalAlpha = 1; x.fillStyle = color; x.textAlign = 'center'; x.textBaseline = 'middle'; x.fillText(t, 128, 33);
|
||||
const tex = new T.CanvasTexture(c); tex.colorSpace = T.SRGBColorSpace; R3.trash.push(tex);
|
||||
const m = new T.SpriteMaterial({ map: tex, transparent: true, depthTest: false, depthWrite: false }); R3.trash.push(m);
|
||||
const s = new T.Sprite(m); s.scale.set(0.9, 0.225, 1); s.renderOrder = 10;
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- camera
|
||||
// orbit around a target on the floor: theta 0 looks from the south (+z)
|
||||
// fit: the racks in view (the room when it has none)
|
||||
function fit() {
|
||||
const { w, d, racks } = R3.room || { w: 5, d: 4 };
|
||||
const b = racks || [0, 0, w, d], sw = b[2] - b[0], sd = b[3] - b[1];
|
||||
R3.orb = { theta: 0.35, phi: 0.95, r: Math.max(sw, sd, 2) * 1.25 + 2.5, t: new R3.T.Vector3((b[0] + b[2]) / 2, 0.8, (b[1] + b[3]) / 2) };
|
||||
R3.maxR = Math.max(w, d) * 4 + 10;
|
||||
}
|
||||
|
||||
function size() {
|
||||
const el = R3.gl && R3.gl.domElement.parentElement; if (!el) return;
|
||||
const w = el.clientWidth, h = el.clientHeight; if (!w || !h) return;
|
||||
R3.gl.setSize(w, h); R3.camera.aspect = w / h; R3.camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
function draw() {
|
||||
if (R3.queued || !R3.gl) return;
|
||||
R3.queued = true;
|
||||
requestAnimationFrame(() => {
|
||||
R3.queued = false; if (!R3.gl || !R3.orb) return;
|
||||
const o = R3.orb, c = R3.camera;
|
||||
c.position.set(o.t.x + o.r * Math.sin(o.phi) * Math.sin(o.theta), o.t.y + o.r * Math.cos(o.phi), o.t.z + o.r * Math.sin(o.phi) * Math.cos(o.theta));
|
||||
c.lookAt(o.t);
|
||||
R3.gl.render(R3.scene, c);
|
||||
});
|
||||
}
|
||||
|
||||
function hitAt(ev) {
|
||||
const rc = R3.gl.domElement.getBoundingClientRect();
|
||||
R3.ray.setFromCamera(new R3.T.Vector2((ev.clientX - rc.left) / rc.width * 2 - 1, -(ev.clientY - rc.top) / rc.height * 2 + 1), R3.camera);
|
||||
const h = R3.ray.intersectObjects(R3.pick, false).find(x => !x.object.isInstancedMesh || x.object.userData.items[x.instanceId]);
|
||||
if (!h) return null;
|
||||
const d = h.object.isInstancedMesh ? h.object.userData.items[h.instanceId] : h.object.userData; // a bus or stub: what it carries
|
||||
return { ...d, point: h.point, obj: h.object };
|
||||
}
|
||||
|
||||
// controls: drag turns, right-drag or shift-drag moves, wheel or pinch zooms;
|
||||
// a click selects a rack (and on the selected rack's servers opens the
|
||||
// machine), a double click brings the rack close
|
||||
function controls(cv) {
|
||||
const pts = new Map(); let drag = null, hoverQ = false;
|
||||
const clampPhi = p => Math.min(1.5, Math.max(0.08, p));
|
||||
const spread = () => { const [a, b] = [...pts.values()]; return Math.hypot(a.x - b.x, a.y - b.y); };
|
||||
cv.style.touchAction = 'none';
|
||||
cv.addEventListener('contextmenu', ev => ev.preventDefault());
|
||||
cv.addEventListener('pointerdown', ev => {
|
||||
try { cv.setPointerCapture(ev.pointerId); } catch (e) { /* not a live pointer */ }
|
||||
pts.set(ev.pointerId, { x: ev.clientX, y: ev.clientY });
|
||||
if (pts.size === 1) drag = { moved: 0, pan: ev.button === 2 || ev.shiftKey || ev.ctrlKey || ev.metaKey, pinch: 0 };
|
||||
else if (drag) { drag.pinch = spread(); drag.moved = 99; }
|
||||
R3.tip.hidden = true;
|
||||
});
|
||||
cv.addEventListener('pointermove', ev => {
|
||||
const p = pts.get(ev.pointerId);
|
||||
if (!p) { // hover: what is under the pointer
|
||||
if (hoverQ) return; hoverQ = true;
|
||||
requestAnimationFrame(() => {
|
||||
hoverQ = false; if (!R3.gl || !R3.orb) return;
|
||||
const h = hitAt(ev), rc = cv.getBoundingClientRect();
|
||||
cv.style.cursor = h ? 'pointer' : 'grab';
|
||||
R3.tip.hidden = !h;
|
||||
if (h) { R3.tip.textContent = h.tip; R3.tip.style.left = (ev.clientX - rc.left + 14) + 'px'; R3.tip.style.top = (ev.clientY - rc.top + 14) + 'px'; }
|
||||
});
|
||||
return;
|
||||
}
|
||||
const dx = ev.clientX - p.x, dy = ev.clientY - p.y, o = R3.orb; if (!o) return;
|
||||
p.x = ev.clientX; p.y = ev.clientY; drag.moved += Math.abs(dx) + Math.abs(dy);
|
||||
if (pts.size >= 2) { const s = spread(); if (drag.pinch) o.r = Math.min(R3.maxR, Math.max(0.8, o.r * drag.pinch / s)); drag.pinch = s; }
|
||||
else if (drag.pan) {
|
||||
const k = o.r * 0.0016, st = Math.sin(o.theta), ct = Math.cos(o.theta);
|
||||
o.t.x += -ct * dx * k - st * dy * k; o.t.z += st * dx * k - ct * dy * k;
|
||||
} else { o.theta -= dx * 0.008; o.phi = clampPhi(o.phi - dy * 0.008); }
|
||||
draw();
|
||||
});
|
||||
const up = ev => {
|
||||
if (!pts.delete(ev.pointerId)) return;
|
||||
if (pts.size || !drag) return;
|
||||
const click = drag.moved < 6 && ev.type === 'pointerup' && ev.button === 0; drag = null;
|
||||
if (!click) return;
|
||||
const at = { clientX: ev.clientX, clientY: ev.clientY }; // a double click is not two clicks
|
||||
clearTimeout(R3.clickT); R3.clickT = setTimeout(() => { if (R3.gl) clicked(at); }, 240);
|
||||
};
|
||||
cv.addEventListener('pointerup', up); cv.addEventListener('pointercancel', up);
|
||||
cv.addEventListener('pointerleave', () => { if (!pts.size) R3.tip.hidden = true; });
|
||||
cv.addEventListener('wheel', ev => {
|
||||
ev.preventDefault(); const o = R3.orb; if (!o) return;
|
||||
o.r = Math.min(R3.maxR, Math.max(0.8, o.r * Math.exp(ev.deltaY * 0.0015))); draw();
|
||||
}, { passive: false });
|
||||
cv.addEventListener('dblclick', ev => {
|
||||
clearTimeout(R3.clickT);
|
||||
const h = hitAt(ev), o = R3.orb; if (!h || !o) return;
|
||||
const p = new R3.T.Vector3(); (h.obj.parent === R3.scene ? h.obj : h.obj.parent).getWorldPosition(p);
|
||||
if (h.obj.isInstancedMesh) p.copy(h.point); // a cable: close in where it was clicked
|
||||
o.t.set(p.x, 1, p.z); o.r = 3.2; draw();
|
||||
});
|
||||
}
|
||||
|
||||
function clicked(ev) {
|
||||
const h = hitAt(ev), c = R3.ctx; if (!c) return;
|
||||
const base = '#/sites/' + encodeURIComponent(c.site) + '/' + encodeURIComponent(c.roomArg);
|
||||
if (!h) { if (SITE.sel) location.hash = base; return; }
|
||||
if (h.id && SITE.sel === h.rack) { location.hash = '#/machine/' + h.id; return; }
|
||||
location.hash = SITE.sel === h.rack ? base : base + '/' + encodeURIComponent(h.rack);
|
||||
}
|
||||
|
||||
function room3dReset() { R3.orb = null; fit(); draw(); }
|
||||
function room3dTurn() { if (R3.orb) { R3.orb.theta += Math.PI; draw(); } }
|
||||
@@ -0,0 +1,363 @@
|
||||
'use strict';
|
||||
// Sites on a map (OpenStreetMap through Leaflet, loaded on first use), a
|
||||
// site's rooms, and a room's floor plan: racks on 600 mm floor tiles, one
|
||||
// tile wide and two deep, coloured by their machines; a rack opens its front
|
||||
// (rackElev). Positions are edited by dragging; racks without one are laid
|
||||
// out in rows facing alternate aisles until someone places them.
|
||||
|
||||
const SITE = { map: null, markers: null, placing: '', sel: '', edit: null };
|
||||
const lc = s => String(s || '').toLowerCase();
|
||||
|
||||
// siteOf: the site a building name belongs to (name or slug, any case)
|
||||
function siteOf(building) {
|
||||
const b = lc(building);
|
||||
return S.sites.find(s => lc(s.name) === b || (s.slug && lc(s.slug) === b));
|
||||
}
|
||||
const inSite = (site, building) => { const so = siteOf(building); return so ? so.name === site.name : lc(building) === lc(site.name); };
|
||||
|
||||
// siteList: the registered sites plus buildings machines or racks name without one
|
||||
function siteList() {
|
||||
const out = S.sites.map(s => ({ ...s }));
|
||||
const extra = new Set([...S.machines.map(m => m.location && m.location.building), ...S.racks.map(r => r.building)].filter(b => b && !siteOf(b)));
|
||||
for (const b of [...extra].sort()) out.push({ name: b, lat: null, lon: null, source: 'unregistered' });
|
||||
return out;
|
||||
}
|
||||
|
||||
// siteRacks: a site's racks, one per room and name whatever their case (a
|
||||
// rack typed in by hand and the same rack from NetBox), NetBox's spelling and
|
||||
// a placed one preferred
|
||||
function siteRacks(site) {
|
||||
const by = new Map();
|
||||
for (const r of S.racks.filter(x => inSite(site, x.building))) {
|
||||
const k = lc(r.room) + '|' + lc(r.name), cur = by.get(k);
|
||||
const score = x => (x.x != null ? 2 : 0) + (S.rooms.some(rm => rm.name === x.room && inSite(site, rm.building)) ? 1 : 0);
|
||||
if (!cur || score(r) > score(cur)) by.set(k, r);
|
||||
}
|
||||
return [...by.values()];
|
||||
}
|
||||
const sameRoom = (a, b) => lc(a) === lc(b);
|
||||
|
||||
function siteStats(site) {
|
||||
const ms = S.machines.filter(m => m.location && inSite(site, m.location.building));
|
||||
const racks = siteRacks(site);
|
||||
const by = {}; for (const m of ms) by[stateClass(m.state)] = (by[stateClass(m.state)] || 0) + 1;
|
||||
const worst = ['bad', 'warn', 'ok', 'idle'].find(k => by[k]) || 'idle';
|
||||
const named = new Map(); // one room per name whatever its case; NetBox's (S.rooms) spelling first
|
||||
for (const n of [...S.rooms.filter(r => inSite(site, r.building)).map(r => r.name), ...racks.map(r => r.room), ...ms.map(m => m.location.room || '')]) if (!named.has(lc(n))) named.set(lc(n), n);
|
||||
const rooms = [...named.values()].sort();
|
||||
return { ms, racks, by, worst, rooms };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- map page
|
||||
function loadLeaflet() {
|
||||
if (window.L) return Promise.resolve();
|
||||
if (!loadLeaflet.p) {
|
||||
loadLeaflet.p = new Promise((ok, bad) => {
|
||||
const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = 'vendor/leaflet/leaflet.css'; document.head.appendChild(css);
|
||||
const js = document.createElement('script'); js.src = 'vendor/leaflet/leaflet.js'; js.onload = ok; js.onerror = () => bad(new Error('leaflet did not load')); document.head.appendChild(js);
|
||||
});
|
||||
}
|
||||
return loadLeaflet.p;
|
||||
}
|
||||
|
||||
function pageSites() {
|
||||
const list = siteList();
|
||||
const rows = list.map(s => {
|
||||
const st = siteStats(s);
|
||||
const where = s.lat != null ? `<span class="mono muted">${s.lat.toFixed(4)}, ${s.lon.toFixed(4)}</span>` : '<span class="muted">not on the map</span>';
|
||||
return [`<a href="#/sites/${encodeURIComponent(s.name)}"><b>${esc(s.name)}</b></a>${s.address ? `<br><span class="muted">${esc(s.address)}</span>` : ''}`,
|
||||
`<span class="pill tag">${esc(s.source)}</span>${s.source === 'netbox' ? ' ' + nbLink('/dcim/sites/' + nbQ({ slug: s.slug || '', name: s.slug ? '' : s.name }), '↗') : ''}`, st.rooms.filter(Boolean).length || '—', st.racks.length, st.ms.length,
|
||||
`<span class="dot ${st.worst}"></span>${esc(groupLine(Object.fromEntries(st.ms.map(m => [m.state, st.ms.filter(x => x.state === m.state).length])), 40) || '—')}`,
|
||||
where, `<button class="btn sm" data-place="${esc(s.name)}">${s.lat != null ? 'move' : 'place on map'}</button>`];
|
||||
});
|
||||
return `<div class="page-head"><h1>Sites</h1><span class="sub">${list.length} sites · map © OpenStreetMap contributors${S.ipam && S.ipam.provider === 'netbox' ? ' · sites, rooms and racks come from NetBox' : ''}</span></div>
|
||||
${SITE.placing ? `<div class="nv-issues"><span class="dot warn"></span>Click the map where <b>${esc(SITE.placing)}</b> is. <a href="#" id="place-cancel">cancel</a></div>` : ''}
|
||||
<div class="card wide" style="margin-bottom:12px;padding:0;overflow:hidden"><div id="site-map"></div></div>
|
||||
<div class="card wide" id="site-list"><h3>Sites</h3><div class="tw">${table(['Site', 'Source', 'Rooms', 'Racks', 'Machines', 'State', 'Location', ''], rows)}</div></div>`;
|
||||
}
|
||||
|
||||
function wireSites() {
|
||||
$$('[data-place]').forEach(b => b.onclick = () => { SITE.placing = b.dataset.place; render(); });
|
||||
const c = $('#place-cancel'); if (c) c.onclick = ev => { ev.preventDefault(); SITE.placing = ''; render(); };
|
||||
loadLeaflet().then(() => {
|
||||
const el = $('#site-map'); if (!el) return;
|
||||
if (SITE.map) { SITE.map.remove(); SITE.map = null; }
|
||||
const map = L.map(el, { worldCopyJump: true, attributionControl: true });
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 19, referrerPolicy: 'strict-origin-when-cross-origin', // OSM's tile policy asks for a referrer
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a> contributors',
|
||||
}).addTo(map);
|
||||
SITE.map = map; SITE.markers = L.layerGroup().addTo(map);
|
||||
sitesMarkers(true);
|
||||
map.on('click', async ev => {
|
||||
if (!SITE.placing) return;
|
||||
const name = SITE.placing; SITE.placing = '';
|
||||
const lat = Math.round(ev.latlng.lat * 1e5) / 1e5, lon = Math.round(L.Util.wrapNum(ev.latlng.lng, [-180, 180], true) * 1e5) / 1e5;
|
||||
try { await api('POST', '/api/v1/sites', { name, lat, lon }); toast(`${name} placed at ${lat}, ${lon}`); refresh(true); } catch (e) { toast(e.message, true); render(); }
|
||||
});
|
||||
if (SITE.placing) el.style.cursor = 'crosshair';
|
||||
}).catch(e => { const el = $('#site-map'); if (el) el.innerHTML = `<div class="empty">${esc(e.message)}</div>`; });
|
||||
}
|
||||
|
||||
// sitesMarkers draws (or redraws, on refresh) one marker per site with coordinates
|
||||
function sitesMarkers(fit) {
|
||||
if (!SITE.map) return;
|
||||
SITE.markers.clearLayers();
|
||||
const pts = [];
|
||||
const color = { bad: getComputedStyle(document.body).getPropertyValue('--danger'), warn: getComputedStyle(document.body).getPropertyValue('--warn'), ok: getComputedStyle(document.body).getPropertyValue('--ok'), idle: getComputedStyle(document.body).getPropertyValue('--muted') };
|
||||
for (const s of siteList()) {
|
||||
if (s.lat == null) continue;
|
||||
const st = siteStats(s);
|
||||
const mk = L.circleMarker([s.lat, s.lon], { radius: 7 + Math.min(14, Math.log2(1 + st.ms.length) * 2), color: color[st.worst].trim(), weight: 2, fillOpacity: 0.45 });
|
||||
mk.bindTooltip(`<b>${esc(s.name)}</b><br>${st.ms.length} machines · ${st.racks.length} racks${st.by.bad ? `<br>${st.by.bad} failed` : ''}`, { direction: 'top' });
|
||||
mk.on('click', () => { if (!SITE.placing) location.hash = '#/sites/' + encodeURIComponent(s.name); });
|
||||
mk.addTo(SITE.markers); pts.push([s.lat, s.lon]);
|
||||
}
|
||||
if (!fit) return;
|
||||
if (pts.length > 1) SITE.map.fitBounds(pts, { padding: [40, 40], maxZoom: 12 });
|
||||
else if (pts.length === 1) SITE.map.setView(pts[0], 12);
|
||||
else SITE.map.setView([30, 15], 2);
|
||||
}
|
||||
|
||||
// refreshSites keeps the map (and the tiles it loaded) on the 5 s refresh
|
||||
function refreshSites() {
|
||||
if (!SITE.map || !$('#site-map') || SITE.placing) return false;
|
||||
sitesMarkers(false);
|
||||
const tmp = document.createElement('div'); tmp.innerHTML = pageSites();
|
||||
const fresh = $('#site-list', tmp); if (fresh) { $('#site-list').replaceWith(fresh); $$('[data-place]').forEach(b => b.onclick = () => { SITE.placing = b.dataset.place; render(); }); }
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- one site
|
||||
function pageSite(name) {
|
||||
const site = siteList().find(s => s.name === name);
|
||||
if (!site) return `<div class="empty">No site ${esc(name)}. <a href="#/sites">Sites</a></div>`;
|
||||
const st = siteStats(site);
|
||||
const cards = st.rooms.map(rm => {
|
||||
const racks = st.racks.filter(r => sameRoom(r.room, rm)), ms = st.ms.filter(m => sameRoom(m.location.room || '', rm));
|
||||
const bad = ms.filter(m => stateClass(m.state) === 'bad').length;
|
||||
return `<a class="card room-card" href="#/sites/${encodeURIComponent(site.name)}/${encodeURIComponent(rm || '-')}"><h3>${esc(rm || '(no room)')}</h3>
|
||||
<div class="room-mini">${floorSVG(site, rm, { mini: true })}</div>
|
||||
<div class="muted">${racks.length} racks · ${ms.length} machines${bad ? ` · <span class="bad">${bad} failed</span>` : ''}</div></a>`;
|
||||
});
|
||||
return `<div class="page-head"><h1><a href="#/sites" class="muted" style="font-weight:400">Sites</a> / ${esc(site.name)}</h1><span class="sub">${esc(site.address || '')} · ${st.racks.length} racks · ${st.ms.length} machines</span>
|
||||
<div class="right">${site.source === 'netbox' ? nbLink('/dcim/sites/' + nbQ({ slug: site.slug || '', name: site.slug ? '' : site.name })) : ''}<button class="btn" id="new-room">New room…</button></div></div>
|
||||
${cards.length ? `<div class="room-cards">${cards.join('')}</div>` : '<div class="empty">No rooms or racks here yet: add a room, or set machines\' location to this site.</div>'}`;
|
||||
}
|
||||
function wireSite(name) {
|
||||
const b = $('#new-room'); if (b) b.onclick = async () => {
|
||||
const v = await dialog(`New room in ${name}`, [{ name: 'name', label: 'Room' }, { name: 'width', label: 'Width (tiles)', value: '0', help: '600 mm floor tiles; 0 = as far as the racks go' }, { name: 'depth', label: 'Depth (tiles)', value: '0' }], '', 'Add');
|
||||
if (!v) return;
|
||||
try { await api('POST', '/api/v1/rooms', { building: name, name: v.name.trim(), width: Number(v.width) || 0, depth: Number(v.depth) || 0 }); refresh(true); } catch (e) { toast(e.message, true); }
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- floor plan
|
||||
// racksOnFloor: the room's racks with positions; racks without one get
|
||||
// automatic places in rows of ten below the placed ones, fronts facing
|
||||
// alternate aisles (cold aisle between rows 0/1, 2/3, ...).
|
||||
function racksOnFloor(site, room) {
|
||||
const racks = siteRacks(site).filter(r => sameRoom(r.room, room))
|
||||
.map(r => ({ ...r, auto: r.x == null, facing: r.facing || 'N' }));
|
||||
const placed = racks.filter(r => !r.auto);
|
||||
let y0 = placed.length ? Math.max(...placed.map(r => r.y + footprint(r).h)) + 2 : 1;
|
||||
racks.filter(r => r.auto).sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })).forEach((r, i) => {
|
||||
const row = Math.floor(i / 10);
|
||||
r.x = 1 + (i % 10); r.y = y0 + row * 4 - (row % 2); r.facing = row % 2 ? 'N' : 'S';
|
||||
});
|
||||
return racks;
|
||||
}
|
||||
const footprint = r => (r.facing === 'E' || r.facing === 'W') ? { w: 2, h: 1 } : { w: 1, h: 2 };
|
||||
|
||||
function rackState(site, r) {
|
||||
const ms = S.machines.filter(m => m.location && inSite(site, m.location.building) && sameRoom(m.location.room || '', r.room) && lc(m.location.rack) === lc(r.name));
|
||||
const used = ms.reduce((a, m) => a + (m.size && m.size.node ? 0 : Math.max(1, (m.size && m.size.u) || 1)), 0) +
|
||||
new Set(ms.filter(m => m.size && m.size.node).map(m => m.location.unit)).size * 2;
|
||||
const worst = ['bad', 'warn', 'ok', 'idle'].find(k => ms.some(m => stateClass(m.state) === k)) || 'vacant';
|
||||
return { ms, used, worst };
|
||||
}
|
||||
|
||||
// roomSize: the room's floor in tiles, as set or as far as its racks go
|
||||
function roomSize(site, room, racks) {
|
||||
const rm = S.rooms.find(r => inSite(site, r.building) && sameRoom(r.name, room)) || {};
|
||||
return { W: Math.max(rm.width || 0, ...racks.map(r => r.x + footprint(r).w + 1), 8), D: Math.max(rm.depth || 0, ...racks.map(r => r.y + footprint(r).h + 1), 6) };
|
||||
}
|
||||
|
||||
// floorSVG draws the room; opt.mini: a thumbnail for the site page
|
||||
function floorSVG(site, room, opt = {}) {
|
||||
const racks = SITE.edit && !opt.mini ? SITE.edit.racks : racksOnFloor(site, room);
|
||||
const T = opt.mini ? 9 : 40;
|
||||
const { W, D } = roomSize(site, room, racks);
|
||||
const o = [`<svg class="floor${opt.mini ? ' mini' : ''}" viewBox="0 0 ${W * T} ${D * T}" width="${W * T}" height="${D * T}"${opt.mini ? ' style="max-width:100%;height:auto"' : ''}>`];
|
||||
o.push(`<rect class="room" x="0.5" y="0.5" width="${W * T - 1}" height="${D * T - 1}"/>`);
|
||||
if (!opt.mini) { for (let x = 1; x < W; x++) o.push(`<line class="tile" x1="${x * T}" y1="0" x2="${x * T}" y2="${D * T}"/>`); for (let y = 1; y < D; y++) o.push(`<line class="tile" x1="0" y1="${y * T}" x2="${W * T}" y2="${y * T}"/>`); }
|
||||
for (const r of racks) {
|
||||
const f = footprint(r), st = rackState(site, r), x = r.x * T, y = r.y * T, w = f.w * T, h = f.h * T;
|
||||
const pct = r.height ? Math.min(1, st.used / r.height) : 0;
|
||||
const front = { N: [x, y, x + w, y], S: [x, y + h, x + w, y + h], E: [x + w, y, x + w, y + h], W: [x, y, x, y + h] }[r.facing];
|
||||
const fillBar = (r.facing === 'E' || r.facing === 'W') ? `<rect class="fill" x="${x + 2}" y="${y + h - 4}" width="${(w - 4) * pct}" height="2"/>` : `<rect class="fill" x="${x + w - 4}" y="${y + 2 + (h - 4) * (1 - pct)}" width="2" height="${(h - 4) * pct}"/>`;
|
||||
const sel = SITE.sel === r.name && !opt.mini;
|
||||
o.push(`<g class="rk ${st.worst}${r.auto ? ' auto' : ''}${sel ? ' sel' : ''}" data-rack="${esc(r.name)}"><title>${esc(`${r.name} · ${st.ms.length} machines · ${st.used}/${r.height}U used${r.auto ? ' · not placed yet (automatic)' : ''}`)}</title>
|
||||
<rect class="body" x="${x + 1}" y="${y + 1}" width="${w - 2}" height="${h - 2}" rx="${opt.mini ? 1 : 3}"/>${opt.mini ? '' : fillBar}
|
||||
<line class="front" x1="${front[0]}" y1="${front[1]}" x2="${front[2]}" y2="${front[3]}"/>
|
||||
${opt.mini ? '' : `<text x="${x + w / 2}" y="${y + h / 2 + 4}" text-anchor="middle">${esc(r.name.length > 6 ? r.name.slice(-6) : r.name)}</text>`}</g>`);
|
||||
}
|
||||
o.push('</svg>');
|
||||
return o.join('');
|
||||
}
|
||||
|
||||
function pageRoom(siteName, roomArg) {
|
||||
const site = siteList().find(s => s.name === siteName);
|
||||
if (!site) return `<div class="empty">No site ${esc(siteName)}.</div>`;
|
||||
const room = roomArg === '-' ? '' : roomArg;
|
||||
const racks = racksOnFloor(site, room);
|
||||
const autoN = racks.filter(r => r.auto).length, v3 = !SITE.edit && roomView.get() === '3d';
|
||||
const selRack = racks.find(r => r.name === SITE.sel);
|
||||
const nbSite = (siteOf(site.name) || {}).slug || '';
|
||||
const panel = selRack ? `<div class="card floor-side"><h3>${esc(selRack.name)} <span class="muted">${selRack.height}U · front ${({ N: 'north', S: 'south', E: 'east', W: 'west' })[selRack.facing]}</span> ${nbLink('/dcim/racks/' + nbQ({ name: selRack.name, site: nbSite }))}</h3>
|
||||
${SITE.edit ? `<div class="actions" style="margin-bottom:8px"><button class="btn sm" id="rk-rotate">Rotate</button></div>` : ''}
|
||||
${rackElev(selRack)}${cablingTable(site, selRack)}</div>`
|
||||
: `<div class="card floor-side"><span class="muted">Click a rack for its front.${autoN ? `<br><br>${autoN} rack(s) have no place yet and are laid out automatically (${v3 ? 'see-through' : 'dashed'}): Edit layout to place them.` : ''}</span>${v3 ? switchTable() : ''}</div>`;
|
||||
const legend = v3 ? `<span>drag: turn · right or shift drag: move · wheel: zoom · double click: go to a rack · a click on a selected rack\'s server opens it · amber outline: height unknown (rack_units)${frontView.get() === 'real' ? ' · fronts: the drive bays of the model, the strip on the left ear is the state' : ''}${cablesView.get() ? ' · the cables run at the back: Turn around' : ''}</span>`
|
||||
: `<span><b class="lg vacant"></b>empty</span><span>thick edge: the front · bar: units used${SITE.edit ? ' · drag to move, select and Rotate' : ''}</span>`;
|
||||
return `<div class="page-head"><h1><a href="#/sites" class="muted" style="font-weight:400">Sites</a> / <a href="#/sites/${encodeURIComponent(site.name)}" class="muted" style="font-weight:400">${esc(site.name)}</a> / ${esc(room || '(no room)')}</h1>
|
||||
<span class="sub">${racks.length} racks · floor tiles of 600 mm</span>
|
||||
<div class="right">${room ? nbLink('/dcim/locations/' + nbQ({ name: room, site: nbSite })) : ''}${SITE.edit ? '<button class="btn" id="fl-cancel">Cancel</button><button class="btn" id="fl-auto">Automatic again</button><button class="btn primary" id="fl-save">Save layout</button>'
|
||||
: `<span class="seg-btns"><button class="btn${v3 ? '' : ' on'}" data-roomview="2d">2D</button><button class="btn${v3 ? ' on' : ''}" data-roomview="3d">3D</button></span>${v3 ? `<span class="seg-btns" title="servers: their fronts as the model has them, or blocks in their state's colour"><button class="btn${frontView.get() === 'real' ? ' on' : ''}" data-fronts="real">Fronts</button><button class="btn${frontView.get() === 'state' ? ' on' : ''}" data-fronts="state">State</button></span><button class="btn${cablesView.get() ? ' on' : ''}" id="r3-cables" title="cables from the servers' ports to their switches (LLDP)">Cables</button><button class="btn" id="r3-turn" title="look from the other side: the backs, the switches and the cables">Turn around</button><button class="btn" id="r3-reset">Reset view</button>` : ''}<button class="btn" id="fl-edit">Edit layout</button>`}</div></div>
|
||||
<div class="floor-wrap"><div class="card floor-card">${v3 ? '<div id="room3d" class="room3d"></div>' : floorSVG(site, room)}
|
||||
<div class="legend"><span><b class="lg ok"></b>deployed / ready</span><span><b class="lg warn"></b>busy / new</span><span><b class="lg bad"></b>failed</span>${legend}</div></div>${panel}</div>`;
|
||||
}
|
||||
|
||||
function wireRoom(siteName, roomArg) {
|
||||
const site = siteList().find(s => s.name === siteName); if (!site) return;
|
||||
const room = roomArg === '-' ? '' : roomArg;
|
||||
$$('[data-roomview]').forEach(b => b.onclick = () => { roomView.set(b.dataset.roomview); render(); });
|
||||
$$('[data-fronts]').forEach(b => b.onclick = () => { frontView.set(b.dataset.fronts); render(); });
|
||||
$$('.floor-side .elev .slot.m').forEach(s => s.onclick = () => { location.hash = '#/machine/' + s.dataset.id; });
|
||||
const e = $('#fl-edit'); if (e) e.onclick = () => { SITE.edit = { racks: racksOnFloor(site, room).map(r => ({ ...r })) }; render(); };
|
||||
const rs = $('#r3-reset'); if (rs) rs.onclick = () => room3dReset();
|
||||
const tu = $('#r3-turn'); if (tu) tu.onclick = () => room3dTurn();
|
||||
const cb = $('#r3-cables'); if (cb) cb.onclick = () => { cablesView.set(!cablesView.get()); render(); };
|
||||
$$('[data-swplace]').forEach(b => b.onclick = () => placeSwitch(site, room, b.dataset.swplace));
|
||||
$$('[data-cable]').forEach(a => a.onclick = ev => { ev.preventDefault(); const [id, port] = a.dataset.cable.split('|'); editCable(id, port); });
|
||||
if ($('#room3d')) { room3d(site, roomArg, racksOnFloor(site, room)); return; }
|
||||
room3dStop();
|
||||
const svg = $('svg.floor'); if (!svg) return;
|
||||
// the selected rack is part of the address (#/sites/SITE/ROOM/RACK): a rack can be linked to
|
||||
$$('svg.floor .rk').forEach(g => g.onclick = () => {
|
||||
if (SITE.dropped) return; // the click that ends a drag is not a selection
|
||||
const base = '#/sites/' + encodeURIComponent(siteName) + '/' + encodeURIComponent(roomArg);
|
||||
if (SITE.edit) { SITE.sel = SITE.sel === g.dataset.rack ? '' : g.dataset.rack; render(); return; }
|
||||
location.hash = SITE.sel === g.dataset.rack ? base : base + '/' + encodeURIComponent(g.dataset.rack);
|
||||
});
|
||||
const c = $('#fl-cancel'); if (c) c.onclick = () => { SITE.edit = null; render(); };
|
||||
const save = async (auto) => {
|
||||
const body = SITE.edit.racks.map(r => {
|
||||
return { building: r.building, room: r.room, name: r.name, x: auto ? null : r.x, y: auto ? null : r.y, facing: auto ? '' : r.facing };
|
||||
});
|
||||
try { await api('POST', '/api/v1/racks/positions', body); SITE.edit = null; toast(auto ? 'racks laid out automatically again' : `layout of ${room || site.name} saved`); refresh(true); } catch (err) { toast(err.message, true); }
|
||||
};
|
||||
const s = $('#fl-save'); if (s) s.onclick = () => save(false);
|
||||
const a = $('#fl-auto'); if (a) a.onclick = () => { if (confirm('Forget the places of every rack in this room?')) save(true); };
|
||||
const rot = $('#rk-rotate'); if (rot) rot.onclick = () => { const r = SITE.edit.racks.find(x => x.name === SITE.sel); if (r) { r.facing = { N: 'E', E: 'S', S: 'W', W: 'N' }[r.facing]; r.auto = false; render(); } };
|
||||
if (!SITE.edit) return;
|
||||
const T = 40;
|
||||
svg.onpointerdown = ev => {
|
||||
const g = ev.target.closest('.rk'); if (!g) return;
|
||||
const r = SITE.edit.racks.find(x => x.name === g.dataset.rack); if (!r) return;
|
||||
SITE.drag = { r, g, x0: ev.clientX, y0: ev.clientY, nx: r.x, ny: r.y, moved: false };
|
||||
try { svg.setPointerCapture(ev.pointerId); } catch (e) { /* not a live pointer */ }
|
||||
};
|
||||
svg.onpointermove = ev => { // only the dragged rack moves until it is dropped
|
||||
const d = SITE.drag; if (!d) return;
|
||||
const scale = svg.getBoundingClientRect().width / svg.viewBox.baseVal.width;
|
||||
d.nx = Math.max(0, d.r.x + Math.round((ev.clientX - d.x0) / scale / T));
|
||||
d.ny = Math.max(0, d.r.y + Math.round((ev.clientY - d.y0) / scale / T));
|
||||
d.moved = d.moved || d.nx !== d.r.x || d.ny !== d.r.y;
|
||||
d.g.setAttribute('transform', `translate(${(d.nx - d.r.x) * T},${(d.ny - d.r.y) * T})`);
|
||||
};
|
||||
svg.onpointerup = () => {
|
||||
const d = SITE.drag; if (!d) return;
|
||||
SITE.drag = null; // before render: renderSites holds off while a drag is on
|
||||
if (!d.moved) return;
|
||||
SITE.dropped = true; setTimeout(() => { SITE.dropped = false; }, 0);
|
||||
d.r.x = d.nx; d.r.y = d.ny; d.r.auto = false; SITE.sel = d.r.name; render();
|
||||
};
|
||||
}
|
||||
|
||||
function renderSites() {
|
||||
const r = route();
|
||||
const main = $('#main');
|
||||
if (r.arg2) {
|
||||
if (SITE.edit && SITE.drag) return;
|
||||
if (!SITE.edit) SITE.sel = r.arg3 ? decodeURIComponent(r.arg3) : ''; main.innerHTML = pageRoom(decodeURIComponent(r.arg), decodeURIComponent(r.arg2)); wireRoom(decodeURIComponent(r.arg), decodeURIComponent(r.arg2)); return; }
|
||||
SITE.edit = null; room3dStop();
|
||||
if (r.arg) { main.innerHTML = pageSite(decodeURIComponent(r.arg)); wireSite(decodeURIComponent(r.arg)); return; }
|
||||
if (main.dataset.view === 'sites' && refreshSites()) return;
|
||||
main.innerHTML = pageSites(); main.dataset.view = 'sites'; wireSites();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- cabling
|
||||
// cablingTable: the selected rack's ports and the switch port each is plugged into (LLDP)
|
||||
function cablingTable(site, rk) {
|
||||
const cab = S.cabling; if (!cab) return '';
|
||||
const ms = S.machines.filter(m => m.location && inSite(site, m.location.building) && sameRoom(m.location.room || '', rk.room) && lc(m.location.rack) === lc(rk.name))
|
||||
.sort((a, b) => (b.location.unit || 0) - (a.location.unit || 0));
|
||||
const rows = [], src = { lldp: 'LLDP', netbox: 'NetBox', local: 'by hand' };
|
||||
for (const m of ms) {
|
||||
const ports = [...((cab.machines.find(x => x.id === m.id) || {}).ports || [])];
|
||||
if (!ports.some(p => p.kind === 'bmc')) ports.push({ name: 'bmc', kind: 'bmc' }); // the BMC's cable is NetBox's or entered here
|
||||
for (const p of ports) {
|
||||
const to = p.switch ? `${esc(p.switch)} <span class="mono">${esc(p.switch_port)}</span> <span class="muted">${src[p.source] || ''}</span>` : `<span class="muted">${p.kind === 'bmc' ? 'none recorded' : p.link === false ? 'no link' : 'no LLDP'}</span>`;
|
||||
rows.push(`<div class="cab-p"><span class="mono">${p.kind === 'bmc' ? 'BMC' : esc(p.name)}</span><span>${to}${p.vlan ? ` <span class="muted">VLAN ${esc(p.vlan)}</span>` : ''}${p.netbox_says ? `<br><span class="warn">NetBox: ${esc(p.netbox_says)}</span>` : ''}</span>
|
||||
${p.source === 'lldp' ? '<span></span>' : `<a href="#" data-cable="${esc(m.id)}|${esc(p.name)}" title="set the switch port by hand">✎</a>`}</div>`);
|
||||
}
|
||||
rows.push('</div>');
|
||||
rows.splice(rows.length - ports.length - 1, 0, `<div class="cab-m"><a href="#/machine/${esc(m.id)}">${esc(nameOf(m))}</a> <span class="muted">U${m.location.unit || '?'}</span>`);
|
||||
}
|
||||
return rows.length ? `<h3 style="margin-top:12px">Cabling</h3>${rows.join('')}` : '';
|
||||
}
|
||||
|
||||
async function editCable(id, port) {
|
||||
const m = S.machines.find(x => x.id === id), cur = (((S.cabling || {}).machines || []).find(x => x.id === id) || { ports: [] }).ports.find(p => p.name === port) || {};
|
||||
const v = await dialog(`${m ? nameOf(m) : id} ${port === 'bmc' ? 'BMC' : port}`, [{ name: 'switch', label: 'Switch', value: cur.source === 'local' ? cur.switch : '' , placeholder: 'oob-1' },
|
||||
{ name: 'switch_port', label: 'Switch port', value: cur.source === 'local' ? cur.switch_port : '', placeholder: 'Gi1/0/7' }],
|
||||
'What the port is plugged into, when LLDP cannot see it (the BMC\'s port always) and NetBox has no cable. Empty: forget the one entered here.', 'Save');
|
||||
if (!v) return;
|
||||
try {
|
||||
if (!v.switch.trim()) await api('DELETE', `/api/v1/cables?machine=${encodeURIComponent(id)}&port=${encodeURIComponent(port)}`);
|
||||
else await api('POST', '/api/v1/cables', { machine: id, port, switch: v.switch.trim(), switch_port: v.switch_port.trim() });
|
||||
refresh(true);
|
||||
} catch (e) { toast(e.message, true); }
|
||||
}
|
||||
|
||||
// switchTable: the switches this room's cables lead to, where they stand
|
||||
function switchTable() {
|
||||
const sws = (typeof R3 !== 'undefined' && R3.switches) || [];
|
||||
if (!sws.length) return '<p class="muted" style="margin-top:12px">No cabling known in this room: the machines\' LLDP saw no switch, and NetBox records no cables for them. Cables can be entered per port (select a rack, ✎ in its cabling list).</p>';
|
||||
const where = s => s.away ? `in ${esc(s.away)}` : s.at ? `${esc(rackLabel(s.at.rack))} U${s.at.unit}${s.at.estimated ? ' <span class="warn">estimated</span>' : ` <span class="muted">${esc(s.source)}</span>`}` : '';
|
||||
return `<h3 style="margin-top:12px">Switches</h3><div class="sw-list">${sws.map(s => `<div class="sw-row"><b class="sw-dot" style="background:${esc(s.color)}"></b>
|
||||
<div><div><b>${esc(s.name)}</b>${s.role === 'oob' ? ' <span class="pill tag" title="out-of-band: BMC ports">OOB</span>' : ''}${s.model ? ` <span class="muted">${esc(s.model)}</span>` : ''} ${s.source === 'netbox' ? nbLink('/dcim/devices/' + nbQ({ name: s.name }), '↗') : ''}</div><div class="muted">${where(s)} · ${s.here} cables</div></div>
|
||||
<button class="btn sm" data-swplace="${esc(s.name)}">place…</button></div>`).join('')}</div>
|
||||
<p class="muted">A switch's place comes from NetBox (its rack and position) or is set here; without either it is drawn where most of its cables are.</p>`;
|
||||
}
|
||||
// switchesAt: the switches in a rack by unit, as the 3D room placed them
|
||||
// (estimates included) or as stored, for the rack's elevation
|
||||
function switchesAt(rk) {
|
||||
const out = {}, k = lc(rk.name);
|
||||
const placed = (typeof R3 !== 'undefined' && R3.switches && R3.switches.length) ? R3.switches.filter(s => s.at && s.at.rack === k && s.at.unit <= (rk.height || 42)).map(s => ({ ...s, unit: s.at.unit, estimated: s.at.estimated }))
|
||||
: ((S.cabling || {}).switches || []).filter(s => s.unit && lc(s.rack) === k && lc(s.room || '') === lc(rk.room || '')).map(s => ({ ...s }));
|
||||
for (const s of placed) out[s.unit] = s;
|
||||
return out;
|
||||
}
|
||||
const rackLabel = k => ((S.racks.find(r => lc(r.name) === k) || {}).name || k);
|
||||
|
||||
async function placeSwitch(site, room, name) {
|
||||
const sw = (R3.switches || []).find(s => s.name === name) || {};
|
||||
const racks = racksOnFloor(site, room);
|
||||
const v = await dialog(`Place ${name}`, [{ name: 'rack', label: 'Rack', type: 'select', value: sw.at ? rackLabel(sw.at.rack) : '', options: racks.map(r => [r.name, r.name]) },
|
||||
{ name: 'unit', label: 'Unit', value: sw.at && sw.at.unit <= 60 ? sw.at.unit : '', help: 'lowest U it takes' }], 'Set by hand, the place stands even when NetBox has another.', 'Place');
|
||||
if (!v) return;
|
||||
const rk = racks.find(r => r.name === v.rack);
|
||||
try { await api('POST', '/api/v1/switches', { name, building: rk.building, room: rk.room, rack: rk.name, unit: Number(v.unit) || 0, height: 1 }); toast(`${name} placed in ${rk.name}`); refresh(true); } catch (e) { toast(e.message, true); }
|
||||
}
|
||||
@@ -33,7 +33,7 @@ a { color: var(--accent); }
|
||||
.side a:hover { background: var(--side-act); } .side a.active { background: var(--side-act); color: #fff; }
|
||||
.side .count { font-size: 11px; opacity: .8; }
|
||||
.side-foot { margin-top: auto; display: flex; flex-direction: column; gap: 6px; padding: 10px; font-size: 11px; }
|
||||
main { padding: 18px 22px; max-width: 1500px; width: 100%; }
|
||||
main { padding: 18px 22px; max-width: 1500px; width: 100%; min-width: 0; }
|
||||
.page-head { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||
.page-head h1 { font-size: 18px; margin: 0; } .page-head .sub { color: var(--muted); }
|
||||
.page-head .right { margin-left: auto; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
|
||||
@@ -105,6 +105,8 @@ td.check, th.check { width: 28px; padding-right: 0; }
|
||||
.elev .slot.m.deploying, .elev .slot.m.releasing, .elev .slot.m.stateless { border-left-color: var(--warn); }
|
||||
.elev .slot.m.deploy-failed, .elev .slot.m.release-failed { border-left-color: var(--danger); }
|
||||
.elev .slot.m:hover { filter: brightness(1.08); }
|
||||
.elev .u { grid-column: 1; } .elev .slot { grid-column: 2; display: flex; align-items: center; gap: 4px; }
|
||||
.elev .slot.node { background-image: repeating-linear-gradient(90deg, transparent 0 24%, var(--line) 24% 25%); }
|
||||
.tree { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.tree .chip.on { background: var(--accent); color: var(--accent-fg); }
|
||||
|
||||
@@ -176,3 +178,70 @@ svg.topo .mach.group.ok rect:first-child { stroke: var(--ok); } svg.topo .mach.g
|
||||
.statebar { display: flex; height: 12px; border-radius: 999px; overflow: hidden; background: var(--pill); margin: 4px 0 10px; }
|
||||
.statebar span { height: 100%; } .statebar .ok { background: var(--ok); } .statebar .warn { background: var(--warn); } .statebar .bad { background: var(--danger); } .statebar .idle { background: var(--line2); }
|
||||
@media (max-width: 1100px) { .ov > .card, .ov > .card.w6, .ov > .card.w8 { grid-column: 1 / -1; } }
|
||||
|
||||
/* network page for large sites (netview.js) */
|
||||
.nv-bar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.nv-bar #nv-q { width: 240px; } .nv-bar .right { margin-left: auto; }
|
||||
.seg-btns { display: inline-flex; } .seg-btns .btn { border-radius: 0; } .seg-btns .btn:first-child { border-radius: 6px 0 0 6px; } .seg-btns .btn:last-child { border-radius: 0 6px 6px 0; margin-left: -1px; }
|
||||
.seg-btns .btn.on { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
|
||||
.nv-issues { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 6px 12px; margin-bottom: 12px; }
|
||||
.nv-issues.ok { color: var(--muted); padding: 9px 12px; }
|
||||
.nv-issue { padding: 4px 0; } .nv-issue[data-ips], .nv-issue[data-nvrack] { cursor: pointer; } .nv-issue[data-ips]:hover, .nv-issue[data-nvrack]:hover { text-decoration: underline; }
|
||||
.nv-issue.bad { color: var(--danger); } .nv-issue.warn { color: var(--warn); }
|
||||
.nv-issues > a { display: inline-block; margin: 2px 0 4px; font-size: 12px; }
|
||||
table.nv-matrix { min-width: 0; } .nv-matrix td, .nv-matrix th { padding: 5px 8px; }
|
||||
.nv-matrix th.nv-rackh { height: 92px; vertical-align: bottom; padding: 4px 2px; width: 26px; min-width: 26px; text-align: center; }
|
||||
.nv-matrix th.nv-rackh span { writing-mode: vertical-rl; transform: rotate(180deg); white-space: nowrap; font-family: var(--mono); text-transform: none; letter-spacing: 0; font-size: 11.5px; }
|
||||
.nv-matrix th.nv-rackh.down span { color: var(--danger); }
|
||||
.nv-matrix td.nv-cell { text-align: center; padding: 5px 2px; font-size: 14px; color: var(--muted); border-left: 1px solid var(--line); }
|
||||
.nv-matrix td.nv-cell.serve { color: var(--ok); } .nv-matrix td.nv-cell.second { opacity: .65; } .nv-matrix td.nv-cell.down { color: var(--danger); opacity: 1; }
|
||||
.nv-matrix tr.nv-fab td { background: var(--hover); font-weight: 600; font-size: 12px; padding: 5px 8px; }
|
||||
.nv-matrix tr.nv-row { cursor: pointer; } .nv-matrix tr.nv-row:hover td { background: var(--hover); }
|
||||
.nv-matrix tr.nv-row.hl td { background: var(--sel); }
|
||||
.nv-matrix td.num, .nv-matrix th.num { text-align: right; }
|
||||
.nv-states { display: flex; height: 4px; border-radius: 2px; overflow: hidden; margin-top: 2px; min-width: 60px; background: var(--pill); }
|
||||
.nv-states i { display: block; } .nv-states i.ok { background: var(--ok); } .nv-states i.warn { background: var(--warn); } .nv-states i.bad { background: var(--danger); } .nv-states i.idle { background: var(--line2); }
|
||||
.nv-matrix .bar { min-width: 70px; }
|
||||
.bar.warn > span { background: var(--warn); } .bar.bad > span { background: var(--danger); }
|
||||
.legend b.g { color: var(--ok); font-weight: 400; } .legend b.r { color: var(--danger); font-weight: 400; }
|
||||
.nv-pathcard { margin-bottom: 12px; } .nv-path { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; padding: 4px 0; }
|
||||
.nv-path .arr { color: var(--muted); }
|
||||
|
||||
/* sites, map, floor plans (sites.js) */
|
||||
#site-map { height: 440px; background: var(--bg); }
|
||||
.room-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
|
||||
.room-card { display: block; text-decoration: none; color: inherit; } .room-card:hover { background: var(--hover); }
|
||||
.room-mini { margin: 4px 0 8px; }
|
||||
.floor-wrap { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 12px; align-items: start; }
|
||||
.floor-card { overflow: auto; } .floor-side { position: sticky; top: 12px; max-height: calc(100vh - 40px); overflow: auto; }
|
||||
@media (max-width: 1100px) { .floor-wrap { grid-template-columns: 1fr; } }
|
||||
svg.floor { display: block; touch-action: none; user-select: none; }
|
||||
svg.floor .room { fill: var(--bg); stroke: var(--line2); }
|
||||
svg.floor .tile { stroke: var(--line); stroke-width: 0.5; }
|
||||
svg.floor .rk { cursor: pointer; } svg.floor .rk .body { fill: var(--pill); stroke: var(--line2); }
|
||||
svg.floor .rk.ok .body { fill: color-mix(in srgb, var(--ok) 28%, var(--panel)); stroke: var(--ok); }
|
||||
svg.floor .rk.warn .body { fill: color-mix(in srgb, var(--warn) 28%, var(--panel)); stroke: var(--warn); }
|
||||
svg.floor .rk.bad .body { fill: color-mix(in srgb, var(--danger) 32%, var(--panel)); stroke: var(--danger); }
|
||||
svg.floor .rk.idle .body { fill: var(--pill); stroke: var(--muted); }
|
||||
svg.floor .rk.auto .body { stroke-dasharray: 3 2; }
|
||||
svg.floor .rk.sel .body { stroke: var(--accent); stroke-width: 2.5; }
|
||||
svg.floor .rk .front { stroke: var(--fg); stroke-width: 3; stroke-linecap: round; }
|
||||
svg.floor.mini .rk .front { stroke-width: 1.5; }
|
||||
svg.floor .rk .fill { fill: var(--accent); }
|
||||
svg.floor .rk text { font-size: 9px; fill: var(--fg); pointer-events: none; font-family: var(--mono); }
|
||||
.legend b.lg { display: inline-block; width: 12px; height: 12px; border-radius: 2px; margin-right: 5px; vertical-align: middle; border: 1px solid var(--line2); }
|
||||
.legend b.lg.ok { background: color-mix(in srgb, var(--ok) 28%, var(--panel)); border-color: var(--ok); }
|
||||
.legend b.lg.warn { background: color-mix(in srgb, var(--warn) 28%, var(--panel)); border-color: var(--warn); }
|
||||
.legend b.lg.bad { background: color-mix(in srgb, var(--danger) 32%, var(--panel)); border-color: var(--danger); }
|
||||
.legend b.lg.vacant { background: var(--pill); }
|
||||
.room3d { position: relative; height: min(72vh, 680px); min-height: 360px; border-radius: 6px; overflow: hidden; }
|
||||
.room3d canvas { display: block; cursor: grab; } .room3d canvas:active { cursor: grabbing; }
|
||||
.r3-tip { position: absolute; pointer-events: none; background: var(--panel); border: 1px solid var(--line2); border-radius: 6px; padding: 4px 8px; font-size: 12px; white-space: nowrap; box-shadow: 0 4px 14px rgba(0,0,0,.18); }
|
||||
.sw-list { display: grid; gap: 6px; } .sw-row { display: grid; grid-template-columns: 12px minmax(0, 1fr) auto; gap: 8px; align-items: center; }
|
||||
.sw-row > div { min-width: 0; overflow-wrap: anywhere; } .sw-dot { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
|
||||
.nb-link { font-size: 12px; font-weight: 400; text-transform: none; letter-spacing: 0; white-space: nowrap; align-self: center; }
|
||||
.page-head .right .nb-link { margin-right: 6px; }
|
||||
.cab-m { margin-top: 8px; border-top: 1px solid var(--line); padding-top: 6px; }
|
||||
.cab-p { display: grid; grid-template-columns: 58px minmax(0, 1fr) 14px; gap: 6px; font-size: 12px; padding: 1px 0; } .cab-p > span { overflow-wrap: anywhere; }
|
||||
.elev .slot.sw { background: var(--hover); border-left: 3px solid var(--accent); padding: 0 6px; font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.btn.on { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); } /* a toggle that is on (Cables), outside a segmented group too */
|
||||
+34
-17
@@ -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
@@ -0,0 +1,26 @@
|
||||
BSD 2-Clause License
|
||||
|
||||
Copyright (c) 2010-2023, Volodymyr Agafonkin
|
||||
Copyright (c) 2010-2011, CloudMade
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
+661
@@ -0,0 +1,661 @@
|
||||
/* required styles */
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||
.leaflet-safari .leaflet-tile-container {
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
display: block;
|
||||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
.leaflet-container img.leaflet-image-layer,
|
||||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaflet-container img.leaflet-tile {
|
||||
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag {
|
||||
-ms-touch-action: pinch-zoom;
|
||||
/* Fallback for FF which doesn't support pinch-zoom */
|
||||
touch-action: none;
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.leaflet-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.leaflet-container a {
|
||||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||
}
|
||||
.leaflet-tile {
|
||||
filter: inherit;
|
||||
visibility: hidden;
|
||||
}
|
||||
.leaflet-tile-loaded {
|
||||
visibility: inherit;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
z-index: 800;
|
||||
}
|
||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||
.leaflet-overlay-pane svg {
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.leaflet-pane { z-index: 400; }
|
||||
|
||||
.leaflet-tile-pane { z-index: 200; }
|
||||
.leaflet-overlay-pane { z-index: 400; }
|
||||
.leaflet-shadow-pane { z-index: 500; }
|
||||
.leaflet-marker-pane { z-index: 600; }
|
||||
.leaflet-tooltip-pane { z-index: 650; }
|
||||
.leaflet-popup-pane { z-index: 700; }
|
||||
|
||||
.leaflet-map-pane canvas { z-index: 100; }
|
||||
.leaflet-map-pane svg { z-index: 200; }
|
||||
|
||||
.leaflet-vml-shape {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.lvml {
|
||||
behavior: url(#default#VML);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
/* control positioning */
|
||||
|
||||
.leaflet-control {
|
||||
position: relative;
|
||||
z-index: 800;
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-top {
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-right {
|
||||
right: 0;
|
||||
}
|
||||
.leaflet-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
.leaflet-left {
|
||||
left: 0;
|
||||
}
|
||||
.leaflet-control {
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
float: right;
|
||||
}
|
||||
.leaflet-top .leaflet-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.leaflet-left .leaflet-control {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
-moz-transition: opacity 0.2s linear;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||
opacity: 1;
|
||||
}
|
||||
.leaflet-zoom-animated {
|
||||
-webkit-transform-origin: 0 0;
|
||||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
svg.leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-tile,
|
||||
.leaflet-pan-anim .leaflet-tile {
|
||||
-webkit-transition: none;
|
||||
-moz-transition: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* cursors */
|
||||
|
||||
.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaflet-grab {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.leaflet-crosshair,
|
||||
.leaflet-crosshair .leaflet-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.leaflet-popup-pane,
|
||||
.leaflet-control {
|
||||
cursor: auto;
|
||||
}
|
||||
.leaflet-dragging .leaflet-grab,
|
||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||
.leaflet-dragging .leaflet-marker-draggable {
|
||||
cursor: move;
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* marker & overlays interactivity */
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-pane > svg path,
|
||||
.leaflet-tile-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* visual tweaks */
|
||||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* general toolbar styles */
|
||||
|
||||
.leaflet-bar {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle {
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
cursor: default;
|
||||
background-color: #f4f4f4;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* zoom control */
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||
text-indent: 1px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
/* layers control */
|
||||
|
||||
.leaflet-control-layers {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers.png);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.leaflet-retina .leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers-2x.png);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.leaflet-control-layers .leaflet-control-layers-list,
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||
display: none;
|
||||
}
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-control-layers-expanded {
|
||||
padding: 6px 10px 6px 6px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
.leaflet-control-layers-scrollbar {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.leaflet-control-layers-selector {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 5px -10px 5px -6px;
|
||||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||
background-image: url(images/marker-icon.png);
|
||||
}
|
||||
|
||||
|
||||
/* attribution and scale controls */
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover,
|
||||
.leaflet-control-attribution a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-attribution-flag {
|
||||
display: inline !important;
|
||||
vertical-align: baseline !important;
|
||||
width: 1em;
|
||||
height: 0.6669em;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
border: 2px solid #777;
|
||||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
white-space: nowrap;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
border-bottom: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||
border-bottom: 2px solid #777;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-attribution,
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
/* popup */
|
||||
|
||||
.leaflet-popup {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 24px 13px 20px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
min-height: 1px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 17px 0;
|
||||
margin: 1.3em 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-top: -1px;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: white;
|
||||
color: #333;
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: #585858;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
|
||||
/* div icon */
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip */
|
||||
/* Base styles for the element that has a tooltip */
|
||||
.leaflet-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Directions */
|
||||
|
||||
.leaflet-tooltip-bottom {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.leaflet-tooltip-top {
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-top:before {
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
bottom: 0;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
top: 0;
|
||||
margin-top: -12px;
|
||||
margin-left: -6px;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-left {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-right {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
right: 0;
|
||||
margin-right: -12px;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
left: 0;
|
||||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
/* Prevent printers from removing background-images of controls. */
|
||||
.leaflet-control {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
/* @preserve
|
||||
* Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com
|
||||
* (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade
|
||||
*/
|
||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).leaflet={})}(this,function(t){"use strict";function l(t){for(var e,i,n=1,o=arguments.length;n<o;n++)for(e in i=arguments[n])t[e]=i[e];return t}var R=Object.create||function(t){return N.prototype=t,new N};function N(){}function a(t,e){var i,n=Array.prototype.slice;return t.bind?t.bind.apply(t,n.call(arguments,1)):(i=n.call(arguments,2),function(){return t.apply(e,i.length?i.concat(n.call(arguments)):arguments)})}var D=0;function h(t){return"_leaflet_id"in t||(t._leaflet_id=++D),t._leaflet_id}function j(t,e,i){var n,o,s=function(){n=!1,o&&(r.apply(i,o),o=!1)},r=function(){n?o=arguments:(t.apply(i,arguments),setTimeout(s,e),n=!0)};return r}function H(t,e,i){var n=e[1],e=e[0],o=n-e;return t===n&&i?t:((t-e)%o+o)%o+e}function u(){return!1}function i(t,e){return!1===e?t:(e=Math.pow(10,void 0===e?6:e),Math.round(t*e)/e)}function W(t){return t.trim?t.trim():t.replace(/^\s+|\s+$/g,"")}function F(t){return W(t).split(/\s+/)}function c(t,e){for(var i in Object.prototype.hasOwnProperty.call(t,"options")||(t.options=t.options?R(t.options):{}),e)t.options[i]=e[i];return t.options}function U(t,e,i){var n,o=[];for(n in t)o.push(encodeURIComponent(i?n.toUpperCase():n)+"="+encodeURIComponent(t[n]));return(e&&-1!==e.indexOf("?")?"&":"?")+o.join("&")}var V=/\{ *([\w_ -]+) *\}/g;function q(t,i){return t.replace(V,function(t,e){e=i[e];if(void 0===e)throw new Error("No value provided for variable "+t);return e="function"==typeof e?e(i):e})}var d=Array.isArray||function(t){return"[object Array]"===Object.prototype.toString.call(t)};function G(t,e){for(var i=0;i<t.length;i++)if(t[i]===e)return i;return-1}var K="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";function Y(t){return window["webkit"+t]||window["moz"+t]||window["ms"+t]}var X=0;function J(t){var e=+new Date,i=Math.max(0,16-(e-X));return X=e+i,window.setTimeout(t,i)}var $=window.requestAnimationFrame||Y("RequestAnimationFrame")||J,Q=window.cancelAnimationFrame||Y("CancelAnimationFrame")||Y("CancelRequestAnimationFrame")||function(t){window.clearTimeout(t)};function x(t,e,i){if(!i||$!==J)return $.call(window,a(t,e));t.call(e)}function r(t){t&&Q.call(window,t)}var tt={__proto__:null,extend:l,create:R,bind:a,get lastId(){return D},stamp:h,throttle:j,wrapNum:H,falseFn:u,formatNum:i,trim:W,splitWords:F,setOptions:c,getParamString:U,template:q,isArray:d,indexOf:G,emptyImageUrl:K,requestFn:$,cancelFn:Q,requestAnimFrame:x,cancelAnimFrame:r};function et(){}et.extend=function(t){function e(){c(this),this.initialize&&this.initialize.apply(this,arguments),this.callInitHooks()}var i,n=e.__super__=this.prototype,o=R(n);for(i in(o.constructor=e).prototype=o,this)Object.prototype.hasOwnProperty.call(this,i)&&"prototype"!==i&&"__super__"!==i&&(e[i]=this[i]);if(t.statics&&l(e,t.statics),t.includes){var s=t.includes;if("undefined"!=typeof L&&L&&L.Mixin){s=d(s)?s:[s];for(var r=0;r<s.length;r++)s[r]===L.Mixin.Events&&console.warn("Deprecated include of L.Mixin.Events: this property will be removed in future releases, please inherit from L.Evented instead.",(new Error).stack)}l.apply(null,[o].concat(t.includes))}return l(o,t),delete o.statics,delete o.includes,o.options&&(o.options=n.options?R(n.options):{},l(o.options,t.options)),o._initHooks=[],o.callInitHooks=function(){if(!this._initHooksCalled){n.callInitHooks&&n.callInitHooks.call(this),this._initHooksCalled=!0;for(var t=0,e=o._initHooks.length;t<e;t++)o._initHooks[t].call(this)}},e},et.include=function(t){var e=this.prototype.options;return l(this.prototype,t),t.options&&(this.prototype.options=e,this.mergeOptions(t.options)),this},et.mergeOptions=function(t){return l(this.prototype.options,t),this},et.addInitHook=function(t){var e=Array.prototype.slice.call(arguments,1),i="function"==typeof t?t:function(){this[t].apply(this,e)};return this.prototype._initHooks=this.prototype._initHooks||[],this.prototype._initHooks.push(i),this};var e={on:function(t,e,i){if("object"==typeof t)for(var n in t)this._on(n,t[n],e);else for(var o=0,s=(t=F(t)).length;o<s;o++)this._on(t[o],e,i);return this},off:function(t,e,i){if(arguments.length)if("object"==typeof t)for(var n in t)this._off(n,t[n],e);else{t=F(t);for(var o=1===arguments.length,s=0,r=t.length;s<r;s++)o?this._off(t[s]):this._off(t[s],e,i)}else delete this._events;return this},_on:function(t,e,i,n){"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1===this._listens(t,e,i)&&(e={fn:e,ctx:i=i===this?void 0:i},n&&(e.once=!0),this._events=this._events||{},this._events[t]=this._events[t]||[],this._events[t].push(e))},_off:function(t,e,i){var n,o,s;if(this._events&&(n=this._events[t]))if(1===arguments.length){if(this._firingCount)for(o=0,s=n.length;o<s;o++)n[o].fn=u;delete this._events[t]}else"function"!=typeof e?console.warn("wrong listener type: "+typeof e):!1!==(e=this._listens(Line truncated
|
||||
//# sourceMappingURL=leaflet.js.map
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
The MIT License
|
||||
|
||||
Copyright © 2010-2023 three.js authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2010-2023 Three.js Authors
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
const t="160",e={LEFT:0,MIDDLE:1,RIGHT:2,ROTATE:0,DOLLY:1,PAN:2},n={ROTATE:0,PAN:1,DOLLY_PAN:2,DOLLY_ROTATE:3},i=0,r=1,s=2,a=3,o=0,l=1,c=2,h=3,u=0,d=1,p=2,m=2,f=0,g=1,_=2,v=3,x=4,y=5,M=100,S=101,b=102,E=103,T=104,w=200,A=201,R=202,C=203,P=204,L=205,I=206,U=207,N=208,D=209,O=210,F=211,B=212,z=213,H=214,V=0,k=1,G=2,W=3,X=4,j=5,q=6,Y=7,Z=0,J=1,K=2,$=0,Q=1,tt=2,et=3,nt=4,it=5,rt=6,st="attached",at="detached",ot=300,lt=301,ct=302,ht=303,ut=304,dt=306,pt=1e3,mt=1001,ft=1002,gt=1003,_t=1004,vt=1004,xt=1005,yt=1005,Mt=1006,St=1007,bt=1007,Et=1008,Tt=1008,wt=1009,At=1010,Rt=1011,Ct=1012,Pt=1013,Lt=1014,It=1015,Ut=1016,Nt=1017,Dt=1018,Ot=1020,Ft=1021,Bt=1023,zt=1024,Ht=1025,Vt=1026,kt=1027,Gt=1028,Wt=1029,Xt=1030,jt=1031,qt=1033,Yt=33776,Zt=33777,Jt=33778,Kt=33779,$t=35840,Qt=35841,te=35842,ee=35843,ne=36196,ie=37492,re=37496,se=37808,ae=37809,oe=37810,le=37811,ce=37812,he=37813,ue=37814,de=37815,pe=37816,me=37817,fe=37818,ge=37819,_e=37820,ve=37821,xe=36492,ye=36494,Me=36495,Se=36283,be=36284,Ee=36285,Te=36286,we=2200,Ae=2201,Re=2202,Ce=2300,Pe=2301,Le=2302,Ie=2400,Ue=2401,Ne=2402,De=2500,Oe=2501,Fe=0,Be=1,ze=2,He=3e3,Ve=3001,ke=3200,Ge=3201,We=0,Xe=1,je="",qe="srgb",Ye="srgb-linear",Ze="display-p3",Je="display-p3-linear",Ke="linear",$e="srgb",Qe="rec709",tn="p3",en=0,nn=7680,rn=7681,sn=7682,an=7683,on=34055,ln=34056,cn=5386,hn=512,un=513,dn=514,pn=515,mn=516,fn=517,gn=518,_n=519,vn=512,xn=513,yn=514,Mn=515,Sn=516,bn=517,En=518,Tn=519,wn=35044,An=35048,Rn=35040,Cn=35045,Pn=35049,Ln=35041,In=35046,Un=35050,Nn=35042,Dn="100",On="300 es",Fn=1035,Bn=2e3,zn=2001;class Hn{addEventListener(t,e){void 0===this._listeners&&(this._listeners={});const n=this._listeners;void 0===n[t]&&(n[t]=[]),-1===n[t].indexOf(e)&&n[t].push(e)}hasEventListener(t,e){if(void 0===this._listeners)return!1;const n=this._listeners;return void 0!==n[t]&&-1!==n[t].indexOf(e)}removeEventListener(t,e){if(void 0===this._listeners)return;const n=this._listeners[t];if(void 0!==n){const t=n.indexOf(e);-1!==t&&n.splice(t,1)}}dispatchEvent(t){if(void 0===this._listeners)return;const e=this._listeners[t.type];if(void 0!==e){t.target=this;const n=e.slice(0);for(let e=0,i=n.length;e<i;e++)n[e].call(this,t);t.target=null}}}const Vn=["00","01","02","03","04","05","06","07","08","09","0a","0b","0c","0d","0e","0f","10","11","12","13","14","15","16","17","18","19","1a","1b","1c","1d","1e","1f","20","21","22","23","24","25","26","27","28","29","2a","2b","2c","2d","2e","2f","30","31","32","33","34","35","36","37","38","39","3a","3b","3c","3d","3e","3f","40","41","42","43","44","45","46","47","48","49","4a","4b","4c","4d","4e","4f","50","51","52","53","54","55","56","57","58","59","5a","5b","5c","5d","5e","5f","60","61","62","63","64","65","66","67","68","69","6a","6b","6c","6d","6e","6f","70","71","72","73","74","75","76","77","78","79","7a","7b","7c","7d","7e","7f","80","81","82","83","84","85","86","87","88","89","8a","8b","8c","8d","8e","8f","90","91","92","93","94","95","96","97","98","99","9a","9b","9c","9d","9e","9f","a0","a1","a2","a3","a4","a5","a6","a7","a8","a9","aa","ab","ac","ad","ae","af","b0","b1","b2","b3","b4","b5","b6","b7","b8","b9","ba","bb","bc","bd","be","bf","c0","c1","c2","c3","c4","c5","c6","c7","c8","c9","ca","cb","cc","cd","ce","cf","d0","d1","d2","d3","d4","d5","d6","d7","d8","d9","da","db","dc","dd","de","df","e0","e1","e2","e3","e4","e5","e6","e7","e8","e9","ea","eb","ec","ed","ee","ef","f0","f1","f2","f3","f4","f5","f6","f7","f8","f9","fa","fb","fc","fd","fe","ff"];let kn=1234567;const Gn=Math.PI/180,Wn=180/Math.PI;function Xn(){const t=4294967295*Math.random()|0,e=4294967295*Math.random()|0,n=4294967295*Math.random()|0,i=4294967295*Math.random()|0;return(Vn[255&t]+Vn[t>>8&255]+Vn[t>>16&255]+Vn[t>>24&255]+"-"+Vn[255&e]+Vn[e>>8&255]+"-"+Vn[e>>16&15|64]+Vn[e>>24&255]+"-"+Vn[63&n|128]+Vn[n>>8&255]+"-"+Vn[n>>16&255]+Vn[n>>24&255]+Vn[255&i]+Vn[i>>8&255]+Vn[i>>16&255]+Vn[i>>24&255]).toLowerCase()}function jn(t,e,n){return Math.max(e,Math.min(n,t))}function qn(t,e){return(t%e+e)%e}function Yn(t,e,n){return(1-n)*t+n*e}function Zn(t){return 0==(t&t-1)&&0!==t}function Jn(t){return Math.pow(2,Math.floor(Math.log(t)/Math.LN2))}function Kn(t,e){switch(e.constructor){case Float32Array:return t;case Uint32Array:return t/4294967295;case Uint16Array:return t/65535;case Uint8Array:return t/255;case Int32Array:return Math.max(t/2147483647,-1);case Int16Array:return Math.max(t/32767,-1);case Int8Array:return Math.max(t/127,-1);default:throw new Error("Invalid component type.")}}function $n(t,e){switch(e.constructor){case Float32Array:return t;case Uint32Array:return Math.round(4294967295*t);case Uint16Array:return Math.round(65535*t);case Uint8Array:return Math.round(255*t);case Int32Array:return Math.round(2147483647*t);case Int16Array:return Math.round(32767*t);case Int8Array:return Math.round(127*t);default:throw new Error("Invalid component type.")}}const Qn={DEG2RAD:Gn,RAD2DEG:Wn,generateUUID:Xn,clamp:jn,euclideanModulo:qn,mapLinear:function(t,e,n,i,r){Line truncated
|
||||
@@ -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
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
[Unit]
|
||||
Description=rollcall: install a rollcall binary the rack controller staged
|
||||
Description=rollcall: install a rollcall binary or trust bundle the rack controller staged
|
||||
|
||||
[Path]
|
||||
PathExists=/var/lib/rollcall/update/ready
|
||||
PathExists=/var/lib/rollcall/update/ca-ready
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,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
@@ -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/
|
||||
Reference in new issue
Block a user