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>
987 lines
29 KiB
Go
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)
|
|
}
|