Files
rollcall/cmd/rollcall/netapi.go
T
Ilker ManapandClaude Opus 5.5 7a007ef048
CI / agent (push) Successful in 3s
CI / site (push) Successful in 3s
CI / go (push) Successful in 36s
Proxy DHCP for networks whose DHCP server stays in charge
A VLAN in proxy mode leaves addresses to the site's DHCP server; the rack
only answers PXE (dnsmasq proxy range + pxe-service), machines keep DHCP
unless a static range is set aside. CLI -proxy-dhcp, web UI mode select,
bootstrap --proxy-dhcp, doctor/discover aware.

dnsmasq 2.91 (and 2.93) loops forever in its proxy path when the proxy
range carries a set: tag: option_filter appends a request's tags to the
context's persistent list, so the next request's stack interface tag
points at itself (found with gdb on the lab rack). Proxy ranges are
untagged and PXE services limited by interface; dhcp-boot/dhcp-match only
for served subnets; no dhcp-host lines for proxied ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:04:10 +01:00

987 lines
29 KiB
Go

package main
// HTTP API and CLI for the network model and rack controllers.
import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"os"
"strconv"
"strings"
)
// ---------------------------------------------------------------- HTTP
func (a *App) netChanged(r *http.Request) {
a.reloadNet(r.Context())
a.hub.bump()
}
func (a *App) handleFabrics(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
fs, err := a.store.Fabrics(r.Context())
if err != nil {
fail(w, 500, err)
return
}
writeJSON(w, 200, fs)
return
}
var f Fabric
if err := json.NewDecoder(r.Body).Decode(&f); err != nil || !tagNameRe.MatchString(f.Name) {
fail(w, 400, errors.New("name is required: lowercase letters, digits, . _ -"))
return
}
if f.Kind != "" && f.Kind != "ethernet" && f.Kind != "infiniband" {
fail(w, 400, errors.New("kind is ethernet or infiniband"))
return
}
if err := a.store.PutFabric(r.Context(), f); err != nil {
fail(w, 500, err)
return
}
a.netChanged(r)
writeJSON(w, 200, f)
}
func (a *App) handleFabricDelete(w http.ResponseWriter, r *http.Request) {
if err := a.store.DeleteFabric(r.Context(), r.PathValue("name")); err != nil {
fail(w, 500, err)
return
}
a.netChanged(r)
writeJSON(w, 200, obj{"deleted": r.PathValue("name")})
}
func (a *App) handleVLANs(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
vs, err := a.store.VLANs(r.Context())
if err != nil {
fail(w, 500, err)
return
}
writeJSON(w, 200, vs)
return
}
var v VLAN
if err := json.NewDecoder(r.Body).Decode(&v); err != nil || v.Fabric == "" || v.VID < 0 || v.VID > 4094 && v.VID < 0x8000 {
fail(w, 400, errors.New("fabric and vid (0 untagged, 1-4094, or an IB pkey) are required"))
return
}
if v.DHCPMode == "proxy" {
v.DHCP = true
} else if v.DHCPMode != "" && v.DHCPMode != "serve" {
fail(w, 400, fmt.Errorf("dhcp_mode %q: serve or proxy", v.DHCPMode))
return
}
if v.DHCP && v.PrimaryRack == "" {
v.PrimaryRack = a.cfg.RackName
}
racks := a.racksSnapshot()
for _, rk := range []string{v.PrimaryRack, v.SecondaryRack} {
if _, ok := racks[rk]; rk != "" && !ok {
fail(w, 400, fmt.Errorf("rack controller %s is not registered", rk))
return
}
}
id, err := a.store.PutVLAN(r.Context(), v)
if err != nil {
fail(w, 500, err)
return
}
v.ID = id
a.netChanged(r)
writeJSON(w, 200, v)
}
func (a *App) handleVLANDelete(w http.ResponseWriter, r *http.Request) {
vid, _ := strconv.Atoi(r.PathValue("vid"))
if err := a.store.DeleteVLAN(r.Context(), r.PathValue("fabric"), vid); err != nil {
fail(w, 500, err)
return
}
a.netChanged(r)
writeJSON(w, 200, obj{"deleted": fmt.Sprintf("%s/%d", r.PathValue("fabric"), vid)})
}
func (a *App) handleSubnets(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
ss, err := a.store.Subnets(r.Context())
if err != nil {
fail(w, 500, err)
return
}
writeJSON(w, 200, ss)
return
}
var sn Subnet
if err := json.NewDecoder(r.Body).Decode(&sn); err != nil {
fail(w, 400, err)
return
}
_, n, err := net.ParseCIDR(sn.CIDR)
if err != nil {
fail(w, 400, errors.New("cidr must be a network, e.g. 10.0.0.0/24 or fd00:1::/64"))
return
}
sn.CIDR = n.String()
if familyOf(sn.CIDR) == 6 {
if ones, _ := n.Mask.Size(); ones > 126 {
fail(w, 400, errors.New("an IPv6 subnet needs room: /126 or shorter (/64 is usual)"))
return
}
}
if sn.Gateway != "" && !sn.contains(sn.Gateway) {
fail(w, 400, fmt.Errorf("gateway %s is outside %s", sn.Gateway, sn.CIDR))
return
}
switch sn.Purpose {
case "", "provisioning", "mgmt", "bmc", "storage", "ib", "other":
default:
fail(w, 400, errors.New("purpose is provisioning, mgmt, bmc, storage, ib or other"))
return
}
if sn.Fabric != "" && sn.VLANID == 0 {
vs, _ := a.store.VLANs(r.Context())
for _, v := range vs {
if v.Fabric == sn.Fabric && v.VID == sn.VID {
sn.VLANID = v.ID
}
}
if sn.VLANID == 0 { // a subnet on a VLAN not declared yet: create it
fs, _ := a.store.Fabrics(r.Context())
known := false
for _, f := range fs {
known = known || f.Name == sn.Fabric
}
if !known {
fail(w, 400, fmt.Errorf("fabric %s does not exist", sn.Fabric))
return
}
if sn.VLANID, err = a.store.PutVLAN(r.Context(), VLAN{Fabric: sn.Fabric, VID: sn.VID}); err != nil {
fail(w, 500, err)
return
}
}
}
for _, other := range a.subnetsSnapshot() {
if other.CIDR == sn.CIDR {
continue
}
if _, on, err := net.ParseCIDR(other.CIDR); err == nil && (on.Contains(n.IP) || n.Contains(on.IP)) {
fail(w, 409, fmt.Errorf("%s overlaps %s", sn.CIDR, other.CIDR))
return
}
}
if err := a.store.PutSubnet(r.Context(), sn); err != nil {
fail(w, 500, err)
return
}
a.adoptIPs(r.Context())
a.netChanged(r)
writeJSON(w, 200, sn)
}
func (a *App) handleSubnetDelete(w http.ResponseWriter, r *http.Request) {
cidr := r.PathValue("cidr")
if ips, _ := a.store.IPs(r.Context(), cidr, ""); len(ips) > 0 && r.URL.Query().Get("force") != "1" {
fail(w, 409, fmt.Errorf("%s still has %d address(es) handed out (force=1 drops them)", cidr, len(ips)))
return
}
if err := a.store.DeleteSubnet(r.Context(), cidr); err != nil {
fail(w, 500, err)
return
}
a.netChanged(r)
writeJSON(w, 200, obj{"deleted": cidr})
}
func (a *App) handleRanges(w http.ResponseWriter, r *http.Request) {
var rg IPRange
if err := json.NewDecoder(r.Body).Decode(&rg); err != nil {
fail(w, 400, err)
return
}
if rg.Kind != "dynamic" && rg.Kind != "reserved" && rg.Kind != "static" {
fail(w, 400, errors.New("kind is dynamic, reserved or static"))
return
}
sn := subnetByRef(a.subnetsSnapshot(), rg.Subnet)
if sn == nil {
fail(w, 404, fmt.Errorf("subnet %s: %w", rg.Subnet, errSubnetUnknown))
return
}
lo, ok1 := parseAddr(rg.Start)
hi, ok2 := parseAddr(rg.End)
if !ok1 || !ok2 || hi.Less(lo) || !sn.contains(rg.Start) || !sn.contains(rg.End) {
fail(w, 400, fmt.Errorf("range %s-%s is not inside %s", rg.Start, rg.End, sn.CIDR))
return
}
rg.Start, rg.End = lo.String(), hi.String()
for _, o := range sn.Ranges {
ol, _ := parseAddr(o.Start)
oh, _ := parseAddr(o.End)
if !oh.Less(lo) && !hi.Less(ol) {
fail(w, 409, fmt.Errorf("overlaps the %s range %s-%s", o.Kind, o.Start, o.End))
return
}
}
rg.Subnet = sn.CIDR
id, err := a.store.AddRange(r.Context(), rg)
if err != nil {
fail(w, 500, err)
return
}
rg.ID = id
a.netChanged(r)
writeJSON(w, 200, rg)
}
func (a *App) handleRangeDelete(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err := a.store.DeleteRange(r.Context(), id); err != nil {
fail(w, 500, err)
return
}
a.netChanged(r)
writeJSON(w, 200, obj{"deleted": id})
}
func (a *App) handleIPs(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
ips, err := a.store.IPs(r.Context(), r.URL.Query().Get("subnet"), r.URL.Query().Get("machine"))
if err != nil {
fail(w, 500, err)
return
}
writeJSON(w, 200, ips)
case http.MethodPost: // reserve one address (a switch, a PDU, a VIP)
var x IPAddr
if err := json.NewDecoder(r.Body).Decode(&x); err != nil {
fail(w, 400, err)
return
}
sn := subnetFor(a.subnetsSnapshot(), x.IP)
if sn == nil {
fail(w, 404, fmt.Errorf("%s is in no subnet", x.IP))
return
}
ok, err := a.claimIP(r.Context(), IPAddr{IP: x.IP, Subnet: sn.CIDR, Kind: "reserved", Hostname: x.Hostname})
if err != nil {
fail(w, 500, err)
return
}
if !ok {
fail(w, 409, fmt.Errorf("%s is taken", x.IP))
return
}
a.netChanged(r)
writeJSON(w, 200, obj{"reserved": x.IP, "subnet": sn.CIDR})
}
}
func (a *App) handleIPDelete(w http.ResponseWriter, r *http.Request) {
if err := a.store.ReleaseIP(r.Context(), r.PathValue("ip")); err != nil {
fail(w, 500, err)
return
}
a.kickIPAM()
a.netChanged(r)
writeJSON(w, 200, obj{"released": r.PathValue("ip")})
}
func validLayout(l NetLayout) error {
if !tagNameRe.MatchString(l.Name) {
return errors.New("name is required: lowercase letters, digits, . _ -")
}
if len(l.Interfaces) == 0 {
return errors.New("a layout needs at least one interface")
}
names := map[string]bool{}
for _, i := range l.Interfaces {
switch i.Type {
case "", "physical":
case "bond":
if len(i.Members) == 0 {
return fmt.Errorf("bond %s has no members", i.Name)
}
case "vlan":
if !names[i.Parent] || i.VID < 1 || i.VID > 4094 {
return fmt.Errorf("vlan %q needs a parent defined above it and a vid 1-4094", i.Name)
}
default:
return fmt.Errorf("interface type %q: physical, bond or vlan", i.Type)
}
for _, lk := range i.Links {
switch lk.Mode {
case "", "auto", "static", "dhcp", "link_up":
default:
return fmt.Errorf("link mode %q: auto, static, dhcp or link_up", lk.Mode)
}
}
n := i.Name
if n == "" && i.Type == "vlan" {
n = fmt.Sprintf("%s.%d", i.Parent, i.VID)
}
names[n] = true
}
return nil
}
func (a *App) handleLayouts(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
ls, err := a.store.Layouts(r.Context())
if err != nil {
fail(w, 500, err)
return
}
ms, _ := a.store.List(r.Context())
for i := range ls {
for _, m := range ms {
if l, _ := a.layoutFor(r.Context(), m); l != nil && strings.HasPrefix(l.Name, ls[i].Name) {
ls[i].Machines++
}
}
}
writeJSON(w, 200, ls)
return
}
var l NetLayout
if err := json.NewDecoder(r.Body).Decode(&l); err != nil {
fail(w, 400, err)
return
}
if err := validLayout(l); err != nil {
fail(w, 400, err)
return
}
if err := a.store.PutLayout(r.Context(), l); err != nil {
fail(w, 500, err)
return
}
writeJSON(w, 200, l)
}
func (a *App) handleLayoutDelete(w http.ResponseWriter, r *http.Request) {
if err := a.store.DeleteLayout(r.Context(), r.PathValue("name")); err != nil {
fail(w, 409, err)
return
}
writeJSON(w, 200, obj{"deleted": r.PathValue("name")})
}
// handleMachineNetwork: GET shows the plan and the network-config it renders
// (without handing out addresses); POST sets the layout and pinned addresses.
func (a *App) handleMachineNetwork(w http.ResponseWriter, r *http.Request) {
m, err := a.store.Get(r.Context(), r.PathValue("id"))
if err != nil {
fail(w, 500, err)
return
}
if r.Method == http.MethodPost {
var req struct {
Layout *string `json:"layout"`
Rack *string `json:"rack_controller"`
Static []struct{ Iface, IP string }
Unstatic []string `json:"unstatic"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
fail(w, 400, err)
return
}
if req.Layout != nil {
if *req.Layout != "" {
ls, _ := a.store.Layouts(r.Context())
found := false
for _, l := range ls {
found = found || l.Name == *req.Layout
}
if !found {
fail(w, 404, fmt.Errorf("layout %s does not exist", *req.Layout))
return
}
}
if err := a.store.SetMachineLayout(r.Context(), m.ID, *req.Layout); err != nil {
fail(w, 500, err)
return
}
}
if req.Rack != nil {
if _, ok := a.racksSnapshot()[*req.Rack]; *req.Rack != "" && !ok {
fail(w, 404, fmt.Errorf("rack controller %s is not registered", *req.Rack))
return
}
if err := a.store.SetRackController(r.Context(), m.ID, *req.Rack); err != nil {
fail(w, 500, err)
return
}
m, _ = a.store.Get(r.Context(), m.ID)
_ = a.writePXEAny(r.Context(), m) // its PXE file moves to the new rack
}
for _, ip := range req.Unstatic {
have, _ := a.store.IPs(r.Context(), "", m.ID)
for _, x := range have {
if x.IP == ip && x.Kind == "static" {
_ = a.store.ReleaseIP(r.Context(), ip)
a.kickIPAM()
}
}
}
for _, st := range req.Static {
sn := subnetFor(a.subnetsSnapshot(), st.IP)
if sn == nil {
fail(w, 400, fmt.Errorf("%s is in no subnet", st.IP))
return
}
have, _ := a.store.IPs(r.Context(), sn.CIDR, m.ID)
for _, x := range have { // one pinned address per interface and subnet
if x.Iface == st.Iface && x.IP != st.IP {
_ = a.store.ReleaseIP(r.Context(), x.IP)
}
}
if ok, err := a.claimIP(r.Context(), IPAddr{IP: st.IP, Subnet: sn.CIDR, MachineID: m.ID, Iface: st.Iface, Kind: "static", Hostname: deref(m.NodeName)}); err != nil {
fail(w, 500, err)
return
} else if !ok {
cur, _ := a.store.IPs(r.Context(), sn.CIDR, m.ID)
mine := false
for _, x := range cur {
mine = mine || x.IP == st.IP
}
if !mine {
fail(w, 409, fmt.Errorf("%s is taken", st.IP))
return
}
_, _ = a.store.pool.Exec(r.Context(), `UPDATE ip_addresses SET kind='static', iface=$2 WHERE ip=$1`, st.IP, st.Iface)
}
}
_ = a.store.AddEvent(r.Context(), m.ID, "network", fmt.Sprintf("layout=%v static=%v unstatic=%v", deref(req.Layout), req.Static, req.Unstatic))
a.netChanged(r)
m, _ = a.store.Get(r.Context(), m.ID)
}
plan, err := a.netPlan(r.Context(), m, false)
if err != nil {
fail(w, 409, err)
return
}
ips, _ := a.store.IPs(r.Context(), "", m.ID)
writeJSON(w, 200, obj{"id": m.ID, "layout": plan.Layout, "plan": plan, "network_config": networkConfig(plan), "addresses": ips,
"rack_controller": a.rackOf(m), "modeled": len(a.subnetsSnapshot()) > 0})
}
// ---------------------------------------------------------------- CLI
func (c *client) netCmd(cmd string, args []string) error {
need := func(n int) {
if len(args) < n {
usage()
}
}
sub := ""
if len(args) > 0 {
sub = args[0]
}
switch cmd {
case "fabric":
switch sub {
case "", "list":
var fs []Fabric
if err := c.do("GET", "/api/v1/fabrics", nil, &fs); err != nil {
return err
}
fmt.Printf("%-16s %-11s %s\n", "FABRIC", "KIND", "COMMENT")
for _, f := range fs {
fmt.Printf("%-16s %-11s %s\n", f.Name, f.Kind, f.Comment)
}
case "add":
need(2)
fs := newFlags("fabric add")
kind := fs.String("kind", "ethernet", "ethernet | infiniband")
comment := fs.String("comment", "", "comment")
_ = fs.Parse(args[2:])
return c.do("POST", "/api/v1/fabrics", Fabric{Name: args[1], Kind: *kind, Comment: *comment}, nil)
case "rm":
need(2)
return c.do("DELETE", "/api/v1/fabrics/"+args[1], nil, nil)
default:
usage()
}
case "vlan":
switch sub {
case "", "list":
var vs []VLAN
if err := c.do("GET", "/api/v1/vlans", nil, &vs); err != nil {
return err
}
fmt.Printf("%-14s %5s %-14s %5s %-6s %-14s %s\n", "FABRIC", "VID", "NAME", "MTU", "DHCP", "RACK", "SECONDARY")
for _, v := range vs {
fmt.Printf("%-14s %5d %-14s %5d %-6s %-14s %s\n", v.Fabric, v.VID, v.Name, v.MTU, orDash(v.DHCPMode), v.PrimaryRack, v.SecondaryRack)
}
case "add", "set":
need(3)
fs := newFlags("vlan add")
name := fs.String("name", "", "name")
mtu := fs.Int("mtu", 1500, "MTU")
dhcp := fs.Bool("dhcp", false, "serve DHCP/PXE on this VLAN")
proxy := fs.Bool("proxy-dhcp", false, "another DHCP server hands out addresses here; only answer PXE (proxy DHCP)")
rack := fs.String("rack", "", "rack controller serving it (default: the region's embedded one)")
sec := fs.String("secondary", "", "second rack controller (the dynamic range is split)")
_ = fs.Parse(args[3:])
vid, _ := strconv.Atoi(args[2])
var v VLAN
mode := ""
if *proxy {
mode = "proxy"
}
if err := c.do("POST", "/api/v1/vlans", VLAN{Fabric: args[1], VID: vid, Name: *name, MTU: *mtu, DHCP: *dhcp || *proxy, DHCPMode: mode, PrimaryRack: *rack, SecondaryRack: *sec}, &v); err != nil {
return err
}
fmt.Printf("vlan %s/%d: dhcp=%s rack=%s\n", v.Fabric, v.VID, orDash(v.DHCPMode), v.PrimaryRack)
case "rm":
need(3)
return c.do("DELETE", "/api/v1/vlans/"+args[1]+"/"+args[2], nil, nil)
default:
usage()
}
case "subnet":
switch sub {
case "", "list":
var ss []Subnet
if err := c.do("GET", "/api/v1/subnets", nil, &ss); err != nil {
return err
}
fmt.Printf("%-24s %-12s %-14s %-12s %-15s %-12s %9s %s\n", "SUBNET", "PURPOSE", "FABRIC/VLAN", "DHCP RACK", "GATEWAY", "DOMAIN", "USED", "RANGES")
for _, s := range ss {
dhcp := "-"
if s.DHCP {
dhcp = s.PrimaryRack
if s.SecondaryRack != "" {
dhcp += "+" + s.SecondaryRack
}
}
var rs []string
for _, r := range s.Ranges {
rs = append(rs, fmt.Sprintf("%s:%s-%s", r.Kind, r.Start, lastOctets(r.End)))
}
size := strconv.Itoa(s.Size)
if s.Size == 0 {
size = "∞"
}
fmt.Printf("%-24s %-12s %-14s %-12s %-15s %-12s %4d/%-4s %s\n", s.CIDR, s.Purpose, fmt.Sprintf("%s/%d", orDash(s.Fabric), s.VID), dhcp, orDash(s.Gateway), orDash(s.Domain), s.Used, size, strings.Join(rs, " "))
}
case "show":
need(2)
var ss []Subnet
if err := c.do("GET", "/api/v1/subnets", nil, &ss); err != nil {
return err
}
for _, s := range ss {
if s.CIDR == args[1] || s.Name == args[1] {
fmt.Printf("%s %s fabric %s vlan %d mtu %d dhcp=%v rack=%s %s\n gateway %s dns %v domain %s\n", s.CIDR, s.Purpose, s.Fabric, s.VID, s.MTU, s.DHCP, s.PrimaryRack, s.SecondaryRack, orDash(s.Gateway), s.DNS, orDash(s.Domain))
for _, r := range s.Ranges {
fmt.Printf(" range %-4d %-8s %s - %s %s\n", r.ID, r.Kind, r.Start, r.End, r.Comment)
}
var ips []IPAddr
if err := c.do("GET", "/api/v1/ips?subnet="+s.CIDR, nil, &ips); err != nil {
return err
}
fmt.Printf(" %d of %d addresses in use\n", len(ips), s.Size)
for _, x := range ips {
fmt.Printf(" %-15s %-8s %-16s %-10s %s\n", x.IP, x.Kind, orDash(x.MachineID), x.Iface, x.Hostname)
}
return nil
}
}
return errNotFound
case "add", "set":
need(2)
fs := newFlags("subnet add")
fabric := fs.String("fabric", "", "fabric (the VLAN is created when missing)")
vid := fs.Int("vid", 0, "VLAN id (0 = untagged)")
purpose := fs.String("purpose", "provisioning", "provisioning | mgmt | bmc | storage | ib | other")
gw := fs.String("gateway", "", "default gateway")
dns := fs.String("dns", "", "comma-separated DNS servers")
domain := fs.String("domain", "", "DNS domain")
name := fs.String("name", "", "name")
comment := fs.String("comment", "", "comment")
_ = fs.Parse(args[2:])
sn := Subnet{CIDR: args[1], Fabric: *fabric, VID: *vid, Purpose: *purpose, Gateway: *gw, Domain: *domain, Name: *name, Comment: *comment, DNS: []string{}}
if *dns != "" {
sn.DNS = strings.Split(*dns, ",")
}
var out Subnet
if err := c.do("POST", "/api/v1/subnets", sn, &out); err != nil {
return err
}
fmt.Printf("subnet %s saved\n", out.CIDR)
case "rm":
need(2)
q := ""
if len(args) > 2 && args[2] == "-force" {
q = "?force=1"
}
return c.do("DELETE", "/api/v1/subnets/"+args[1]+q, nil, nil)
default:
usage()
}
case "range":
switch sub {
case "add":
need(5)
fs := newFlags("range add")
comment := fs.String("comment", "", "comment")
_ = fs.Parse(args[5:])
var rg IPRange
if err := c.do("POST", "/api/v1/ranges", IPRange{Subnet: args[1], Kind: args[2], Start: args[3], End: args[4], Comment: *comment}, &rg); err != nil {
return err
}
fmt.Printf("range %d: %s %s-%s on %s\n", rg.ID, rg.Kind, rg.Start, rg.End, rg.Subnet)
case "rm":
need(2)
return c.do("DELETE", "/api/v1/ranges/"+args[1], nil, nil)
default:
usage()
}
case "ip":
switch sub {
case "", "list":
fs := newFlags("ip list")
subnet := fs.String("subnet", "", "only this subnet")
_ = fs.Parse(args[min(1, len(args)):])
var ips []IPAddr
if err := c.do("GET", "/api/v1/ips?subnet="+*subnet, nil, &ips); err != nil {
return err
}
fmt.Printf("%-15s %-18s %-8s %-16s %-10s %s\n", "IP", "SUBNET", "KIND", "MACHINE", "IFACE", "HOSTNAME")
for _, x := range ips {
fmt.Printf("%-15s %-18s %-8s %-16s %-10s %s\n", x.IP, x.Subnet, x.Kind, orDash(x.MachineID), x.Iface, x.Hostname)
}
case "reserve":
need(2)
fs := newFlags("ip reserve")
host := fs.String("name", "", "what it is (a switch, a VIP)")
_ = fs.Parse(args[2:])
return c.do("POST", "/api/v1/ips", IPAddr{IP: args[1], Hostname: *host}, nil)
case "release":
need(2)
return c.do("DELETE", "/api/v1/ips/"+args[1], nil, nil)
default:
usage()
}
case "layout":
switch sub {
case "", "list":
var ls []NetLayout
if err := c.do("GET", "/api/v1/layouts", nil, &ls); err != nil {
return err
}
fmt.Printf("%-18s %8s %-50s %s\n", "LAYOUT", "MACHINES", "INTERFACES", "COMMENT")
for _, l := range ls {
fmt.Printf("%-18s %8d %-50s %s\n", l.Name, l.Machines, trunc(layoutSummary(l), 50), l.Comment)
}
case "show":
need(2)
var ls []NetLayout
if err := c.do("GET", "/api/v1/layouts", nil, &ls); err != nil {
return err
}
for _, l := range ls {
if l.Name == args[1] {
b, _ := json.MarshalIndent(l, "", " ")
fmt.Println(string(b))
return nil
}
}
return errNotFound
case "add":
need(3)
fs := newFlags("layout add")
comment := fs.String("comment", "", "comment")
_ = fs.Parse(args[3:])
b, err := readFileArg(args[2])
if err != nil {
return err
}
var l NetLayout
if err := json.Unmarshal(b, &l); err != nil {
return fmt.Errorf("%s: %w", args[2], err)
}
l.Name = args[1]
if *comment != "" {
l.Comment = *comment
}
return c.do("POST", "/api/v1/layouts", l, nil)
case "rm":
need(2)
return c.do("DELETE", "/api/v1/layouts/"+args[1], nil, nil)
default:
usage()
}
case "net":
need(2)
switch sub {
case "show":
var d obj
if err := c.do("GET", "/api/v1/machines/"+args[1]+"/network", nil, &d); err != nil {
return err
}
printNet(d)
case "set":
fs := newFlags("net set")
layout := fs.String("layout", "\x00", "layout name (\"\" = from tags / default)")
rack := fs.String("rack", "\x00", "rack controller serving it (a machine moved to another segment)")
var statics, unstatic multiFlag
fs.Var(&statics, "static", "IFACE=IP pins an address (repeatable)")
fs.Var(&unstatic, "unstatic", "IP drops a pinned address (repeatable)")
_ = fs.Parse(args[2:])
req := obj{"unstatic": []string(unstatic)}
if *layout != "\x00" {
req["layout"] = *layout
}
if *rack != "\x00" {
req["rack_controller"] = *rack
}
var st []obj
for _, s := range statics {
i, ip, ok := strings.Cut(s, "=")
if !ok {
return fmt.Errorf("-static wants IFACE=IP")
}
st = append(st, obj{"iface": i, "ip": ip})
}
req["static"] = st
var d obj
if err := c.do("POST", "/api/v1/machines/"+args[1]+"/network", req, &d); err != nil {
return err
}
printNet(d)
default:
usage()
}
case "controllers", "controller":
switch {
case cmd == "controller" && sub == "rm":
need(2)
return c.do("DELETE", "/api/v1/controllers/"+args[1], nil, nil)
case cmd == "controller" && sub == "join-code":
fs := newFlags("controller join-code")
name := fs.String("name", "", "bind the code to this rack controller name")
ttl := fs.Int("ttl", 60, "minutes the code is good for")
addr := fs.String("addr", "", "host:port the rack reaches the region's HTTPS at (default: how you reached it)")
_ = fs.Parse(args[1:])
var d obj
if err := c.do("POST", "/api/v1/controllers/join", obj{"name": *name, "ttl_minutes": *ttl, "addr": *addr}, &d); err != nil {
return err
}
fmt.Printf("%s\n\none use, until %s; region %s, CA %s\non the rack host: rollcall rack join <code> -url http://<its address on the segment>\n",
str(d["code"]), str(d["expires"]), str(d["addr"]), str(d["ca_fingerprint"]))
return nil
default:
var rs []RackController
if err := c.do("GET", "/api/v1/controllers", nil, &rs); err != nil {
return err
}
fmt.Printf("%-14s %-9s %-7s %-24s %8s %-22s %-20s %s\n", "CONTROLLER", "KIND", "ONLINE", "URL", "MACHINES", "VERSION", "LAST SEEN", "INTERFACES")
for _, r := range rs {
var ifs []string
for _, i := range r.Interfaces {
ifs = append(ifs, i.Name+"="+strings.Join(i.Addrs, ","))
}
fmt.Printf("%-14s %-9s %-7v %-24s %8d %-22s %-20s %s\n", r.Name, r.Kind, r.Online, r.URL, r.Machines, orDash(r.Version), timeStr(r.LastSeen), strings.Join(ifs, " "))
}
}
case "dhcp":
need(1)
data, err := c.raw("GET", "/api/v1/controllers/"+args[0]+"/dhcp")
if err != nil {
return err
}
os.Stdout.Write(data)
case "ipam":
return c.ipamCmd(sub, args)
}
return nil
}
func (c *client) ipamCmd(sub string, args []string) error {
printRep := func(rep ipamReport) {
for _, x := range []struct {
k string
v []string
}{{"created", rep.Created}, {"updated", rep.Updated}, {"deleted", rep.Deleted}, {"conflict", rep.Conflicts}} {
for _, ip := range x.v {
fmt.Printf("%-9s %s\n", x.k, ip)
}
}
dry := ""
if rep.DryRun {
dry = " (dry run, nothing changed)"
}
fmt.Printf("%s: %d created, %d updated, %d deleted, %d conflicts%s\n", orDash(rep.Provider),
len(rep.Created), len(rep.Updated), len(rep.Deleted), len(rep.Conflicts), dry)
if rep.Error != "" {
fmt.Println("error:", rep.Error)
}
}
switch sub {
case "", "status":
var st struct {
Provider string `json:"provider"`
URL string `json:"url"`
Zone string `json:"dns_zone"`
Tag string `json:"tag"`
Field string `json:"machine_field"`
Last ipamReport `json:"last_sync"`
}
if err := c.do("GET", "/api/v1/ipam", nil, &st); err != nil {
return err
}
if st.Provider == "" {
fmt.Println("built-in IPAM only (set ipam.provider in config.json for NetBox)")
return nil
}
fmt.Printf("provider %s %s\nzone %s\nowner tag %q, custom field %q\n", st.Provider, st.URL, orDash(st.Zone), st.Tag, st.Field)
if st.Last.At.IsZero() {
fmt.Println("last sync never")
return nil
}
fmt.Printf("last sync %s\n", st.Last.At.Local().Format("2006-01-02 15:04:05"))
printRep(st.Last)
case "sync":
fs := newFlags("ipam sync")
dry := fs.Bool("dry", false, "show what would change")
_ = fs.Parse(args[min(1, len(args)):])
q := ""
if *dry {
q = "?dry=1"
}
var rep ipamReport
err := c.do("POST", "/api/v1/ipam/sync"+q, nil, &rep)
if rep.Provider != "" || rep.Error != "" {
printRep(rep)
}
return err
case "prefixes", "import":
var res struct {
Prefixes []extPrefix `json:"prefixes"`
Imported []string `json:"imported"`
}
method := "GET"
if sub == "import" {
method = "POST"
}
if err := c.do(method, "/api/v1/ipam/prefixes", nil, &res); err != nil {
return err
}
fmt.Printf("%-22s %-6s %-5s %-10s %s\n", "PREFIX", "LOCAL", "VLAN", "SITE", "DESCRIPTION")
for _, p := range res.Prefixes {
vid := "-"
if p.VID > 0 {
vid = strconv.Itoa(p.VID)
}
local := "no"
if p.Local {
local = "yes"
if p.Comment != "" {
local = p.Comment
}
}
fmt.Printf("%-22s %-6s %-5s %-10s %s\n", p.Prefix, local, vid, orDash(p.Site), p.Desc)
}
if sub == "prefixes" {
fmt.Println("prefixes tagged for Rollcall; `rollcall ipam import` creates the missing subnets")
}
default:
usage()
}
return nil
}
type multiFlag []string
func (m *multiFlag) String() string { return strings.Join(*m, ",") }
func (m *multiFlag) Set(v string) error { *m = append(*m, v); return nil }
func lastOctets(ip string) string {
if strings.Contains(ip, ":") {
if i := strings.LastIndex(ip, ":"); i >= 0 {
return ip[i:]
}
}
if i := strings.LastIndex(ip, "."); i >= 0 {
return ip[i+1:]
}
return ip
}
func layoutSummary(l NetLayout) string {
var p []string
for _, i := range l.Interfaces {
var links []string
for _, lk := range i.Links {
links = append(links, lk.Subnet+":"+orDash(lk.Mode))
}
switch i.Type {
case "bond":
p = append(p, fmt.Sprintf("%s(%s)[%s]", orDash(i.Name), strings.Join(i.Members, "+"), strings.Join(links, ",")))
case "vlan":
p = append(p, fmt.Sprintf("%s.%d[%s]", i.Parent, i.VID, strings.Join(links, ",")))
default:
p = append(p, fmt.Sprintf("%s[%s]", orDash(i.Select), strings.Join(links, ",")))
}
}
return strings.Join(p, " ")
}
func printNet(d obj) {
fmt.Printf("%s layout %s rack controller %s\n", str(d["id"]), str(d["layout"]), str(d["rack_controller"]))
if d["modeled"] == false {
fmt.Println(" no subnets are defined: deployed machines use DHCP (rollcall subnet add …)")
}
plan, _ := d["plan"].(obj)
for _, iv := range toList(plan["interfaces"]) {
i, _ := iv.(obj)
desc := str(i["type"])
switch desc {
case "physical":
desc = str(i["mac"])
case "bond":
var ms []string
for _, x := range toList(i["members"]) {
ms = append(ms, str(x))
}
desc = "bond " + str(i["bond_mode"]) + " of " + strings.Join(ms, ",")
case "vlan":
desc = fmt.Sprintf("vlan %s on %s", str(i["vid"]), str(i["parent"]))
}
mtu := ""
if num(i["mtu"]) > 0 {
mtu = fmt.Sprintf(" mtu %d", int(num(i["mtu"])))
}
fmt.Printf(" %-12s %s%s\n", str(i["name"]), desc, mtu)
for _, lv := range toList(i["links"]) {
l, _ := lv.(obj)
ip := str(l["ip"])
if ip == "" && str(l["mode"]) == "auto" {
ip = "(allocated at deploy)"
} else if ip != "" {
ip = fmt.Sprintf("%s/%d", ip, int(num(l["prefix"])))
}
gw := ""
if l["default_gateway"] == true {
gw = " default via " + str(l["gateway"])
}
fmt.Printf(" %-8s %-18s %s%s\n", str(l["mode"]), orDash(str(l["subnet"])), ip, gw)
}
}
for _, w := range toList(plan["warnings"]) {
fmt.Printf(" ! %s\n", str(w))
}
b, _ := json.MarshalIndent(d["network_config"], " ", " ")
fmt.Printf(" network-config:\n %s\n", b)
}