diff --git a/cmd/rollcall/client.go b/cmd/rollcall/client.go index c8e8d5b..3a2a464 100644 --- a/cmd/rollcall/client.go +++ b/cmd/rollcall/client.go @@ -289,7 +289,7 @@ func runClient(cmd string, args []string) error { return c.locationCmd(args) case "bmc": return c.bmcCmd(args) - case "fabric", "vlan", "subnet", "range", "ip", "layout", "net", "controllers", "controller", "dhcp", "ipam", "dns": + case "fabric", "vlan", "subnet", "range", "ip", "layout", "net", "controllers", "controller", "dhcp", "ipam", "dns", "reservations": return c.netCmd(cmd, args) case "admin-cert": return c.adminCertCmd(args) diff --git a/cmd/rollcall/config.go b/cmd/rollcall/config.go index 3073e23..0924785 100644 --- a/cmd/rollcall/config.go +++ b/cmd/rollcall/config.go @@ -57,6 +57,8 @@ type Config struct { // DNS records for machines written directly (PowerDNS API or RFC 2136) DNS DNSConfig `json:"dns"` + // fixed leases on the site's DHCP server for machines on proxy-mode VLANs (Kea, OpenWrt) + DHCPRes DHCPResConfig `json:"dhcp_reservations"` // external IPAM authority (NetBox; Infoblox at sites that run it) IPAM IPAMConfig `json:"ipam"` diff --git a/cmd/rollcall/dhcpres.go b/cmd/rollcall/dhcpres.go new file mode 100644 index 0000000..a71d078 --- /dev/null +++ b/cmd/rollcall/dhcpres.go @@ -0,0 +1,335 @@ +package main + +// DHCP reservations on the site's DHCP server. On a VLAN in proxy mode the +// site's server hands out addresses and Rollcall only answers PXE; with a +// reservation driver the machine still gets the address Rollcall planned for +// it, as a fixed lease (MAC -> IP) on that server (Infoblox's fixedaddress). +// +// kea ISC Kea with the host_cmds hook (open source since Kea 3.0): +// reservation-add/-del/-get-all over the control API; Rollcall's +// reservations carry user-context {"rollcall": } +// openwrt rpcd/ubus: "host" sections in /etc/config/dhcp with an +// option rollcall '' +// +// Reservations Rollcall did not make are never changed or deleted; one in +// the way (same MAC or address) is reported as a conflict. + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "os" + "sort" + "strings" + "sync" + "time" +) + +type DHCPResConfig struct { + Provider string `json:"provider"` // "" | kea | openwrt + URL string `json:"url"` // kea: control API (http://kea:8000); openwrt: http(s)://router (rpcd at /ubus) + User string `json:"user"` + PasswordFile string `json:"password_file"` + CAFile string `json:"ca_file"` + SyncInterval string `json:"sync_interval"` + // kea: write the configuration file after changes (reservations kept in + // the server's memory, i.e. no hosts database); default true + Persist *bool `json:"persist,omitempty"` +} + +// dhcpRes is one fixed lease. +type dhcpRes struct { + MAC string `json:"mac"` + IP string `json:"ip"` + Hostname string `json:"hostname,omitempty"` + Machine string `json:"machine,omitempty"` + Subnet string `json:"subnet"` // CIDR + ref string // provider's handle (OpenWrt section) +} + +func (r dhcpRes) key() string { return r.Subnet + " " + r.MAC } + +type dhcpResProvider interface { + Name() string + // Owned: Rollcall's reservations in these subnets, and the MAC/IP pairs others hold. + Owned(ctx context.Context, subnets []string) (owned []dhcpRes, foreign []dhcpRes, err error) + Put(ctx context.Context, r dhcpRes) error + Delete(ctx context.Context, r dhcpRes) error + // Done is called after a pass that changed something (Kea: config-write). + Done(ctx context.Context) error +} + +type dhcpResReport struct { + Provider string `json:"provider"` + At time.Time `json:"at"` + DryRun bool `json:"dry_run,omitempty"` + Put []string `json:"put"` + Deleted []string `json:"deleted"` + Conflicts []string `json:"conflicts"` + Skipped []string `json:"skipped,omitempty"` // addresses without a known MAC + Error string `json:"error,omitempty"` +} + +func (r *dhcpResReport) fail(e string) { r.Error = joinErr(r.Error, e) } + +type dhcpResState struct { + mu sync.Mutex + last dhcpResReport + kick chan struct{} +} + +func newDHCPResProvider(c DHCPResConfig) (dhcpResProvider, error) { + if c.Provider == "" { + return nil, nil + } + if c.URL == "" { + return nil, errors.New("dhcp_reservations.url is required") + } + pw := "" + if c.PasswordFile != "" { + b, err := os.ReadFile(c.PasswordFile) + if err != nil { + return nil, fmt.Errorf("dhcp_reservations.password_file: %w", err) + } + pw = strings.TrimSpace(string(b)) + } + hc, err := httpClientCA(c.CAFile, 30*time.Second) + if err != nil { + return nil, fmt.Errorf("dhcp_reservations.ca_file: %w", err) + } + switch c.Provider { + case "kea": + return &keaProvider{url: strings.TrimRight(c.URL, "/"), user: c.User, pass: pw, hc: hc, persist: c.Persist == nil || *c.Persist}, nil + case "openwrt": + return &openwrtProvider{url: strings.TrimRight(c.URL, "/") + "/ubus", user: orDefault(c.User, "root"), pass: pw, hc: hc}, nil + } + return nil, fmt.Errorf("dhcp_reservations.provider %q: kea or openwrt", c.Provider) +} + +func (a *App) startDHCPRes() error { + p, err := newDHCPResProvider(a.cfg.DHCPRes) + if err != nil || p == nil { + return err + } + a.dhcpRes = p + a.dhcpResSt = &dhcpResState{kick: make(chan struct{}, 1)} + every := 5 * time.Minute + if d, err := time.ParseDuration(a.cfg.DHCPRes.SyncInterval); err == nil && d >= 10*time.Second { + every = d + } + go func() { + a.kickDHCPRes() + for { + select { + case <-a.dhcpResSt.kick: + case <-time.After(every): + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + rep := a.reconcileDHCPRes(ctx, false) + cancel() + if rep.Error != "" { + log.Printf("dhcp reservations %s: %s", p.Name(), rep.Error) + } else if n := len(rep.Put) + len(rep.Deleted) + len(rep.Conflicts); n > 0 { + log.Printf("dhcp reservations %s: put %d, deleted %d, conflicts %d %v", p.Name(), len(rep.Put), len(rep.Deleted), len(rep.Conflicts), rep.Conflicts) + } + } + }() + log.Printf("dhcp reservations: %s at %s", p.Name(), a.cfg.DHCPRes.URL) + return nil +} + +func (a *App) kickDHCPRes() { + if a.dhcpResSt == nil { + return + } + select { + case a.dhcpResSt.kick <- struct{}{}: + default: + } +} + +// desiredDHCPRes: every address Rollcall gave a machine on a proxy-mode +// subnet, with the MAC of the interface it belongs to. +func desiredDHCPRes(ips []IPAddr, subnets []Subnet, machines map[string]*Machine) (map[string]dhcpRes, []string) { + want := map[string]dhcpRes{} + var skipped []string + for _, x := range ips { + sn := subnetFor(subnets, x.IP) + m := machines[x.MachineID] + if sn == nil || sn.DHCPMode != "proxy" || m == nil || len(m.MACs) == 0 { + continue + } + ad, ok := parseAddr(x.IP) + if !ok || !ad.Is4() { // DHCPv6 reservations key on DUIDs, not MACs + continue + } + mac := "" + if x.IP == deref(m.MgmtIP) || x.Iface == "" { + mac = m.MACs[0] + } + for _, n := range machineNICs(m) { + if n.Name == x.Iface && n.MAC != "" { + mac = n.MAC + } + } + if mac == "" { + skipped = append(skipped, x.IP+" ("+x.Iface+": no single MAC)") + continue + } + r := dhcpRes{MAC: mac, IP: ad.String(), Hostname: deref(m.NodeName), Machine: m.ID, Subnet: sn.CIDR} + want[r.key()] = r + } + return want, skipped +} + +func (a *App) reconcileDHCPRes(ctx context.Context, dry bool) dhcpResReport { + rep := dhcpResReport{At: time.Now().UTC(), DryRun: dry, Put: []string{}, Deleted: []string{}, Conflicts: []string{}} + if a.dhcpRes == nil { + rep.Error = "no DHCP reservation provider configured (dhcp_reservations.provider in config.json)" + return rep + } + rep.Provider = a.dhcpRes.Name() + a.dhcpResSt.mu.Lock() + defer a.dhcpResSt.mu.Unlock() + defer func() { + if !dry { + a.dhcpResSt.last = rep + } + }() + ips, err := a.store.IPs(ctx, "", "") + if err != nil { + rep.Error = err.Error() + return rep + } + list, err := a.store.List(ctx) + if err != nil { + rep.Error = err.Error() + return rep + } + machines := map[string]*Machine{} + for _, m := range list { + machines[m.ID] = m + } + subnets := a.subnetsSnapshot() + want, skipped := desiredDHCPRes(ips, subnets, machines) + rep.Skipped = skipped + var proxied []string + for _, sn := range subnets { + if sn.DHCPMode == "proxy" && familyOf(sn.CIDR) == 4 { + proxied = append(proxied, sn.CIDR) + } + } + if len(proxied) == 0 { + return rep + } + a.dhcpResWith(ctx, want, proxied, &rep) + return rep +} + +func (a *App) dhcpResWith(ctx context.Context, want map[string]dhcpRes, subnets []string, rep *dhcpResReport) { + owned, foreign, err := a.dhcpRes.Owned(ctx, subnets) + if err != nil { + rep.Error = err.Error() + return + } + have := map[string]dhcpRes{} + for _, o := range owned { + have[o.key()] = o + } + keys := make([]string, 0, len(want)) + for k := range want { + keys = append(keys, k) + } + sort.Strings(keys) + changed := false + for _, k := range keys { + w := want[k] + if o, ok := have[k]; ok { + delete(have, k) + if o.IP == w.IP && o.Hostname == w.Hostname && o.Machine == w.Machine { + continue + } + if rep.DryRun { + rep.Put = append(rep.Put, w.MAC+" "+w.IP) + continue + } + if err := a.dhcpRes.Delete(ctx, o); err != nil { // replaced: the old one goes first + rep.fail(fmt.Sprintf("replace %s: %v", w.MAC, err)) + continue + } + changed = true + } else if c := clash(foreign, w); c != "" { + rep.Conflicts = append(rep.Conflicts, fmt.Sprintf("%s %s: %s", w.MAC, w.IP, c)) + continue + } + if rep.DryRun { + rep.Put = append(rep.Put, w.MAC+" "+w.IP) + continue + } + if err := a.dhcpRes.Put(ctx, w); err != nil { + rep.fail(fmt.Sprintf("put %s %s: %v", w.MAC, w.IP, err)) + continue + } + changed = true + rep.Put = append(rep.Put, w.MAC+" "+w.IP) + } + gone := make([]dhcpRes, 0, len(have)) + for _, o := range have { + gone = append(gone, o) + } + sort.Slice(gone, func(i, j int) bool { return gone[i].key() < gone[j].key() }) + for _, o := range gone { + if !rep.DryRun { + if err := a.dhcpRes.Delete(ctx, o); err != nil { + rep.fail(fmt.Sprintf("delete %s: %v", o.MAC, err)) + continue + } + changed = true + } + rep.Deleted = append(rep.Deleted, o.MAC+" "+o.IP) + } + if changed { + if err := a.dhcpRes.Done(ctx); err != nil { + rep.fail("save: " + err.Error()) + } + } +} + +// clash: a reservation someone else holds for this MAC or this address. +func clash(foreign []dhcpRes, w dhcpRes) string { + for _, f := range foreign { + if f.Subnet != w.Subnet { + continue + } + switch { + case strings.EqualFold(f.MAC, w.MAC): + return "the MAC is reserved there for " + f.IP + " (not Rollcall's)" + case f.IP == w.IP: + return "the address is reserved there for " + f.MAC + " (not Rollcall's)" + } + } + return "" +} + +func (a *App) handleDHCPRes(w http.ResponseWriter, r *http.Request) { + if a.dhcpRes == nil { + writeJSON(w, 200, obj{"provider": "", "comment": "no DHCP reservation provider (dhcp_reservations in config.json)"}) + return + } + a.dhcpResSt.mu.Lock() + last := a.dhcpResSt.last + a.dhcpResSt.mu.Unlock() + writeJSON(w, 200, obj{"provider": a.dhcpRes.Name(), "url": a.cfg.DHCPRes.URL, "last_sync": last}) +} + +func (a *App) handleDHCPResSync(w http.ResponseWriter, r *http.Request) { + if a.dhcpRes == nil { + fail(w, 409, errors.New("no DHCP reservation provider configured")) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) + defer cancel() + writeJSON(w, 200, a.reconcileDHCPRes(ctx, r.URL.Query().Get("dry") == "1")) +} diff --git a/cmd/rollcall/dhcpres_kea.go b/cmd/rollcall/dhcpres_kea.go new file mode 100644 index 0000000..624abf5 --- /dev/null +++ b/cmd/rollcall/dhcpres_kea.go @@ -0,0 +1,217 @@ +package main + +// Kea: the control API (Kea 3.0's own HTTP control socket, or the Control +// Agent before it) with the host_cmds hook. Subnet ids come from +// config-get; reservations carry user-context {"rollcall": }; +// config-write keeps reservations made in memory (no hosts database). + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/netip" + "strings" +) + +type keaProvider struct { + url, user, pass string + hc *http.Client + persist bool + service *bool // send "service": ["dhcp4"] (a Control Agent wants it; Kea 3.0's own socket refuses it) +} + +func (k *keaProvider) Name() string { return "kea" } + +type keaAnswer struct { + Result int `json:"result"` + Text string `json:"text"` + Arguments json.RawMessage `json:"arguments"` +} + +// call sends one command; result 0 (success) and 3 (empty) are not errors. +func (k *keaProvider) call(ctx context.Context, command string, args any) (*keaAnswer, error) { + try := func(service bool) (*keaAnswer, error) { + body := map[string]any{"command": command} + if args != nil { + body["arguments"] = args + } + if service { + body["service"] = []string{"dhcp4"} + } + b, _ := json.Marshal(body) + req, err := http.NewRequestWithContext(ctx, "POST", k.url, bytes.NewReader(b)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + if k.user != "" { + req.SetBasicAuth(k.user, k.pass) + } + resp, err := k.hc.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("kea %s: %s %s", command, resp.Status, oneLine(string(data))) + } + var many []keaAnswer // the Control Agent answers with a list, one per service + if json.Unmarshal(data, &many) == nil && len(many) > 0 { + return &many[0], nil + } + var one keaAnswer + if err := json.Unmarshal(data, &one); err != nil { + return nil, fmt.Errorf("kea %s: %w", command, err) + } + return &one, nil + } + if k.service == nil { // learn once whether this endpoint wants "service" + ans, err := try(false) + if err == nil && !(ans.Result == 1 && strings.Contains(strings.ToLower(ans.Text), "service")) { + f := false + k.service = &f + return ans, nil + } + t := true + k.service = &t + } + return try(*k.service) +} + +func (k *keaProvider) ok(ctx context.Context, command string, args any) (*keaAnswer, error) { + ans, err := k.call(ctx, command, args) + if err != nil { + return nil, err + } + switch ans.Result { + case 0, 3: + return ans, nil + case 2: + return nil, fmt.Errorf("kea does not know %s (the host_cmds hook is needed: open source since Kea 3.0)", command) + } + return nil, fmt.Errorf("kea %s: %s", command, ans.Text) +} + +// subnetIDs maps CIDR -> Kea subnet id (also inside shared networks). +func (k *keaProvider) subnetIDs(ctx context.Context) (map[string]int, error) { + ans, err := k.ok(ctx, "config-get", nil) + if err != nil { + return nil, err + } + type sub struct { + ID int `json:"id"` + Subnet string `json:"subnet"` + } + var cfg struct { + Dhcp4 struct { + Subnet4 []sub `json:"subnet4"` + SharedNetworks []struct { + Subnet4 []sub `json:"subnet4"` + } `json:"shared-networks"` + } `json:"Dhcp4"` + } + if err := json.Unmarshal(ans.Arguments, &cfg); err != nil { + return nil, fmt.Errorf("kea config-get: %w", err) + } + out := map[string]int{} + add := func(s sub) { + if p, err := netip.ParsePrefix(s.Subnet); err == nil { + out[p.Masked().String()] = s.ID + } + } + for _, s := range cfg.Dhcp4.Subnet4 { + add(s) + } + for _, n := range cfg.Dhcp4.SharedNetworks { + for _, s := range n.Subnet4 { + add(s) + } + } + return out, nil +} + +func (k *keaProvider) Owned(ctx context.Context, subnets []string) ([]dhcpRes, []dhcpRes, error) { + ids, err := k.subnetIDs(ctx) + if err != nil { + return nil, nil, err + } + var owned, foreign []dhcpRes + for _, cidr := range subnets { + id, ok := ids[cidr] + if !ok { + return nil, nil, fmt.Errorf("kea serves no subnet %s", cidr) + } + ans, err := k.ok(ctx, "reservation-get-all", map[string]any{"subnet-id": id}) + if err != nil { + return nil, nil, err + } + var got struct { + Hosts []struct { + HWAddress string `json:"hw-address"` + IPAddress string `json:"ip-address"` + Hostname string `json:"hostname"` + UserContext map[string]any `json:"user-context"` + } `json:"hosts"` + } + _ = json.Unmarshal(ans.Arguments, &got) + for _, h := range got.Hosts { + r := dhcpRes{MAC: normMAC(h.HWAddress), IP: h.IPAddress, Hostname: h.Hostname, Subnet: cidr} + if m, ok := h.UserContext["rollcall"].(string); ok && m != "" { + r.Machine = m + owned = append(owned, r) + } else { + foreign = append(foreign, r) + } + } + } + return owned, foreign, nil +} + +func (k *keaProvider) subnetID(ctx context.Context, cidr string) (int, error) { + ids, err := k.subnetIDs(ctx) + if err != nil { + return 0, err + } + id, ok := ids[cidr] + if !ok { + return 0, fmt.Errorf("kea serves no subnet %s", cidr) + } + return id, nil +} + +func (k *keaProvider) Put(ctx context.Context, r dhcpRes) error { + id, err := k.subnetID(ctx, r.Subnet) + if err != nil { + return err + } + res := map[string]any{"subnet-id": id, "hw-address": r.MAC, "ip-address": r.IP, "user-context": map[string]any{"rollcall": r.Machine}} + if r.Hostname != "" { + res["hostname"] = r.Hostname + } + _, err = k.ok(ctx, "reservation-add", map[string]any{"reservation": res}) + return err +} + +func (k *keaProvider) Delete(ctx context.Context, r dhcpRes) error { + id, err := k.subnetID(ctx, r.Subnet) + if err != nil { + return err + } + _, err = k.ok(ctx, "reservation-del", map[string]any{"subnet-id": id, "identifier-type": "hw-address", "identifier": r.MAC}) + return err +} + +func (k *keaProvider) Done(ctx context.Context) error { + if !k.persist { + return nil + } + if _, err := k.ok(ctx, "config-write", map[string]any{}); err != nil { + return errors.New("reservations are in effect but not saved: " + err.Error()) + } + return nil +} diff --git a/cmd/rollcall/dhcpres_openwrt.go b/cmd/rollcall/dhcpres_openwrt.go new file mode 100644 index 0000000..ee3eca6 --- /dev/null +++ b/cmd/rollcall/dhcpres_openwrt.go @@ -0,0 +1,204 @@ +package main + +// OpenWrt: static leases are "host" sections of /etc/config/dhcp, reached +// through rpcd's ubus JSON-RPC (POST /ubus). Rollcall's carry option +// rollcall ''. The rpcd user needs uci read/write on "dhcp" (and rc +// to reload dnsmasq): see the README for the ACL. + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/netip" + "strings" + "sync" +) + +type openwrtProvider struct { + url, user, pass string + hc *http.Client + mu sync.Mutex + sid string +} + +func (o *openwrtProvider) Name() string { return "openwrt" } + +var errUbusDenied = errors.New("ubus: access denied") + +// rpc is one ubus call; it logs in (again) when the session is missing or expired. +func (o *openwrtProvider) rpc(ctx context.Context, object, method string, args map[string]any) (json.RawMessage, error) { + o.mu.Lock() + sid := o.sid + o.mu.Unlock() + if sid == "" { + if err := o.login(ctx); err != nil { + return nil, err + } + sid = o.sid + } + res, err := o.raw(ctx, sid, object, method, args) + if errors.Is(err, errUbusDenied) { // expired session + if err := o.login(ctx); err != nil { + return nil, err + } + res, err = o.raw(ctx, o.sid, object, method, args) + } + return res, err +} + +func (o *openwrtProvider) login(ctx context.Context) error { + res, err := o.raw(ctx, "00000000000000000000000000000000", "session", "login", map[string]any{"username": o.user, "password": o.pass}) + if err != nil { + return fmt.Errorf("openwrt login: %w", err) + } + var s struct { + SID string `json:"ubus_rpc_session"` + } + if json.Unmarshal(res, &s) != nil || s.SID == "" { + return errors.New("openwrt login: no session") + } + o.mu.Lock() + o.sid = s.SID + o.mu.Unlock() + return nil +} + +var ubusErrors = map[int]string{1: "invalid command", 2: "invalid argument", 3: "method not found", 4: "not found", 5: "no data", 6: "permission denied", 7: "timeout", 8: "not supported", 9: "unknown error", 10: "connection failed"} + +func (o *openwrtProvider) raw(ctx context.Context, sid, object, method string, args map[string]any) (json.RawMessage, error) { + if args == nil { + args = map[string]any{} + } + b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "call", "params": []any{sid, object, method, args}}) + req, err := http.NewRequestWithContext(ctx, "POST", o.url, bytes.NewReader(b)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := o.hc.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + var out struct { + Result []json.RawMessage `json:"result"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(data, &out); err != nil { + return nil, fmt.Errorf("ubus %s %s: %s %s", object, method, resp.Status, oneLine(string(data))) + } + if out.Error != nil { + if out.Error.Code == -32002 { + return nil, errUbusDenied + } + return nil, fmt.Errorf("ubus %s %s: %s", object, method, out.Error.Message) + } + if len(out.Result) == 0 { + return nil, fmt.Errorf("ubus %s %s: empty answer", object, method) + } + var code int + _ = json.Unmarshal(out.Result[0], &code) + if code != 0 { + if code == 6 { + return nil, fmt.Errorf("ubus %s %s: permission denied (rpcd ACL for this user)", object, method) + } + return nil, fmt.Errorf("ubus %s %s: %s", object, method, orDefault(ubusErrors[code], fmt.Sprint(code))) + } + if len(out.Result) < 2 { + return json.RawMessage("{}"), nil + } + return out.Result[1], nil +} + +func (o *openwrtProvider) Owned(ctx context.Context, subnets []string) ([]dhcpRes, []dhcpRes, error) { + res, err := o.rpc(ctx, "uci", "get", map[string]any{"config": "dhcp", "type": "host"}) + if err != nil { + if strings.Contains(err.Error(), "not found") { // no host sections yet + return nil, nil, nil + } + return nil, nil, err + } + var got struct { + Values map[string]map[string]any `json:"values"` + } + if err := json.Unmarshal(res, &got); err != nil { + return nil, nil, err + } + var prefixes []netip.Prefix + for _, s := range subnets { + if p, err := netip.ParsePrefix(s); err == nil { + prefixes = append(prefixes, p) + } + } + var owned, foreign []dhcpRes + for name, sec := range got.Values { + ip, _ := sec["ip"].(string) + ad, err := netip.ParseAddr(ip) + if err != nil { + continue + } + subnet := "" + for _, p := range prefixes { + if p.Contains(ad) { + subnet = p.Masked().String() + } + } + if subnet == "" { + continue + } + var mac string + switch v := sec["mac"].(type) { // a string, or a list of MACs + case string: + mac = strings.Fields(v)[0] + case []any: + if len(v) > 0 { + mac, _ = v[0].(string) + } + } + r := dhcpRes{MAC: normMAC(mac), IP: ad.String(), Subnet: subnet, ref: name} + r.Hostname, _ = sec["name"].(string) + if m, _ := sec["rollcall"].(string); m != "" { + r.Machine = m + owned = append(owned, r) + } else { + foreign = append(foreign, r) + } + } + return owned, foreign, nil +} + +func (o *openwrtProvider) Put(ctx context.Context, r dhcpRes) error { + values := map[string]any{"mac": r.MAC, "ip": r.IP, "rollcall": r.Machine} + if r.Hostname != "" { + values["name"] = r.Hostname + } + _, err := o.rpc(ctx, "uci", "add", map[string]any{"config": "dhcp", "type": "host", "values": values}) + return err +} + +func (o *openwrtProvider) Delete(ctx context.Context, r dhcpRes) error { + if r.ref == "" { + return errors.New("no section to delete") + } + _, err := o.rpc(ctx, "uci", "delete", map[string]any{"config": "dhcp", "section": r.ref}) + return err +} + +// Done commits /etc/config/dhcp and reloads dnsmasq. +func (o *openwrtProvider) Done(ctx context.Context) error { + if _, err := o.rpc(ctx, "uci", "commit", map[string]any{"config": "dhcp"}); err != nil { + return err + } + if _, err := o.rpc(ctx, "rc", "init", map[string]any{"name": "dnsmasq", "action": "reload"}); err != nil { + return fmt.Errorf("committed, but dnsmasq not reloaded: %w", err) + } + return nil +} diff --git a/cmd/rollcall/dhcpres_test.go b/cmd/rollcall/dhcpres_test.go new file mode 100644 index 0000000..5ef1dfb --- /dev/null +++ b/cmd/rollcall/dhcpres_test.go @@ -0,0 +1,277 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "sort" + "strings" + "sync" + "testing" +) + +func TestDesiredDHCPRes(t *testing.T) { + inv, _ := json.Marshal(obj{"network": []obj{{"name": "eth0", "mac": "52:54:00:00:00:01"}, {"name": "eth1", "mac": "52:54:00:00:00:02"}}}) + mg := "10.214.0.50" + name := "fresh01" + m := &Machine{ID: "m-1", MACs: []string{"52:54:00:00:00:01", "52:54:00:00:00:02"}, MgmtIP: &mg, NodeName: &name, Inventory: inv} + subnets := []Subnet{{CIDR: "10.214.0.0/24", DHCPMode: "proxy"}, {CIDR: "10.215.0.0/24", DHCPMode: "proxy"}, {CIDR: "10.216.0.0/24"}, {CIDR: "fd00:214::/64", DHCPMode: "proxy"}} + ips := []IPAddr{ + {IP: "10.214.0.50", MachineID: "m-1", Iface: "eth0"}, + {IP: "10.215.0.50", MachineID: "m-1", Iface: "eth1"}, + {IP: "10.216.0.50", MachineID: "m-1", Iface: "eth1"}, // served by Rollcall itself: no reservation + {IP: "fd00:214::50", MachineID: "m-1", Iface: "eth0"}, // DHCPv6 keys on DUIDs + {IP: "10.215.0.60", MachineID: "m-1", Iface: "bond0"}, // no single MAC + {IP: "10.214.0.70", MachineID: "", Kind: "reserved"}, // not a machine's + } + want, skipped := desiredDHCPRes(ips, subnets, map[string]*Machine{"m-1": m}) + var got []string + for _, r := range want { + got = append(got, r.MAC+"="+r.IP+"/"+r.Hostname) + } + sort.Strings(got) + if strings.Join(got, " ") != "52:54:00:00:00:01=10.214.0.50/fresh01 52:54:00:00:00:02=10.215.0.50/fresh01" || len(skipped) != 1 { + t.Errorf("want %v skipped %v", got, skipped) + } +} + +// fakeKea answers the control API like Kea 3.0's own socket (no "service") +// or a Control Agent (wants "service", answers with a list). +type fakeKea struct { + mu sync.Mutex + agent bool + noHooks bool + hosts []obj + commands []string + written int +} + +func (f *fakeKea) handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + if u, p, ok := r.BasicAuth(); !ok || u != "rollcall" || p != "pw" { + w.WriteHeader(401) + return + } + var req struct { + Command string `json:"command"` + Service []string `json:"service"` + Arguments json.RawMessage `json:"arguments"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + f.commands = append(f.commands, req.Command) + answer := func(o obj) { + if f.agent { + _ = json.NewEncoder(w).Encode([]obj{o}) + return + } + _ = json.NewEncoder(w).Encode(o) + } + if f.agent && len(req.Service) == 0 { + answer(obj{"result": 1, "text": "service not specified"}) + return + } + if !f.agent && len(req.Service) > 0 { + answer(obj{"result": 1, "text": "'service' parameter is not supported here"}) + return + } + var args obj + _ = json.Unmarshal(req.Arguments, &args) + switch req.Command { + case "config-get": + answer(obj{"result": 0, "arguments": obj{"Dhcp4": obj{"subnet4": []obj{{"id": 7, "subnet": "10.214.0.0/24"}}}}}) + case "reservation-get-all": + if f.noHooks { + answer(obj{"result": 2, "text": "'reservation-get-all' command not supported."}) + return + } + answer(obj{"result": 0, "arguments": obj{"hosts": f.hosts}}) + case "reservation-add": + res := args["reservation"].(map[string]any) + for _, h := range f.hosts { + if h["hw-address"] == res["hw-address"] || h["ip-address"] == res["ip-address"] { + answer(obj{"result": 1, "text": "Host already exists."}) + return + } + } + f.hosts = append(f.hosts, res) + answer(obj{"result": 0, "text": "Host added."}) + case "reservation-del": + for i, h := range f.hosts { + if h["hw-address"] == args["identifier"] { + f.hosts = append(f.hosts[:i], f.hosts[i+1:]...) + answer(obj{"result": 0, "text": "Host deleted."}) + return + } + } + answer(obj{"result": 3, "text": "Host not deleted (not found)."}) + case "config-write": + f.written++ + answer(obj{"result": 0}) + } + }) +} + +func keaFor(t *testing.T, f *fakeKea) (*keaProvider, func()) { + srv := httptest.NewServer(f.handler()) + return &keaProvider{url: srv.URL, user: "rollcall", pass: "pw", hc: srv.Client(), persist: true}, srv.Close +} + +func TestKeaReservations(t *testing.T) { + for _, agent := range []bool{false, true} { + f := &fakeKea{agent: agent, hosts: []obj{ + {"hw-address": "52:54:00:aa:aa:aa", "ip-address": "10.214.0.9", "hostname": "printer"}, // someone else's + {"hw-address": "52:54:00:00:00:09", "ip-address": "10.214.0.99", "user-context": obj{"rollcall": "m-old"}}, + }} + k, done := keaFor(t, f) + a := &App{dhcpRes: k, dhcpResSt: &dhcpResState{}} + want := map[string]dhcpRes{} + for _, r := range []dhcpRes{ + {MAC: "52:54:00:00:00:01", IP: "10.214.0.50", Hostname: "fresh01", Machine: "m-1", Subnet: "10.214.0.0/24"}, + {MAC: "52:54:00:00:00:02", IP: "10.214.0.9", Machine: "m-2", Subnet: "10.214.0.0/24"}, // the printer's address + } { + want[r.key()] = r + } + rep := dhcpResReport{} + a.dhcpResWith(context.Background(), want, []string{"10.214.0.0/24"}, &rep) + if rep.Error != "" || strings.Join(rep.Put, ",") != "52:54:00:00:00:01 10.214.0.50" || len(rep.Conflicts) != 1 || strings.Join(rep.Deleted, ",") != "52:54:00:00:00:09 10.214.0.99" { + t.Errorf("agent=%v report %+v", agent, rep) + } + var added obj + for _, h := range f.hosts { + if h["hw-address"] == "52:54:00:00:00:01" { + added = h + } + } + if dig(added, "user-context", "rollcall") != "m-1" || added["hostname"] != "fresh01" || added["subnet-id"] != float64(7) || f.written != 1 { + t.Errorf("agent=%v added %v written %d", agent, added, f.written) + } + // a second pass changes nothing + rep = dhcpResReport{} + a.dhcpResWith(context.Background(), want, []string{"10.214.0.0/24"}, &rep) + if len(rep.Put)+len(rep.Deleted) != 0 || f.written != 1 { + t.Errorf("agent=%v second pass %+v", agent, rep) + } + done() + } + f := &fakeKea{noHooks: true} + k, done := keaFor(t, f) + defer done() + if _, _, err := k.Owned(context.Background(), []string{"10.214.0.0/24"}); err == nil || !strings.Contains(err.Error(), "host_cmds") { + t.Errorf("without host_cmds: %v", err) + } + if _, _, err := k.Owned(context.Background(), []string{"10.99.0.0/24"}); err == nil || !strings.Contains(err.Error(), "no subnet") { + t.Errorf("unknown subnet: %v", err) + } +} + +// fakeUbus is rpcd's JSON-RPC with uci on "dhcp" and rc. +type fakeUbus struct { + mu sync.Mutex + sessions map[string]bool + sections map[string]map[string]any + n int + commits int + reloads int + logins int +} + +func (f *fakeUbus) handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + b, _ := io.ReadAll(r.Body) + var req struct { + Params []json.RawMessage `json:"params"` + } + _ = json.Unmarshal(b, &req) + var sid, object, method string + var args map[string]any + _ = json.Unmarshal(req.Params[0], &sid) + _ = json.Unmarshal(req.Params[1], &object) + _ = json.Unmarshal(req.Params[2], &method) + _ = json.Unmarshal(req.Params[3], &args) + reply := func(v ...any) { _ = json.NewEncoder(w).Encode(obj{"jsonrpc": "2.0", "id": 1, "result": v}) } + if object == "session" && method == "login" { + if args["username"] != "rollcall" || args["password"] != "pw" { + reply(6) + return + } + f.logins++ + s := "s" + strings.Repeat("x", f.logins) + f.sessions[s] = true + reply(0, obj{"ubus_rpc_session": s}) + return + } + if !f.sessions[sid] { + _ = json.NewEncoder(w).Encode(obj{"jsonrpc": "2.0", "id": 1, "error": obj{"code": -32002, "message": "Access denied"}}) + return + } + switch object + "." + method { + case "uci.get": + vals := obj{} + for k, v := range f.sections { + vals[k] = v + } + reply(0, obj{"values": vals}) + case "uci.add": + f.n++ + name := "cfg" + strings.Repeat("0", f.n) + sec := map[string]any{".type": "host"} + for k, v := range args["values"].(map[string]any) { + sec[k] = v + } + f.sections[name] = sec + reply(0, obj{"section": name}) + case "uci.delete": + delete(f.sections, args["section"].(string)) + reply(0) + case "uci.commit": + f.commits++ + reply(0) + case "rc.init": + f.reloads++ + reply(0) + default: + reply(3) + } + }) +} + +func TestOpenWrtReservations(t *testing.T) { + f := &fakeUbus{sessions: map[string]bool{}, sections: map[string]map[string]any{ + "lan_printer": {".type": "host", "name": "printer", "mac": "52:54:00:aa:aa:aa", "ip": "10.214.0.9"}, + "other_net": {".type": "host", "mac": []any{"52:54:00:bb:bb:bb"}, "ip": "192.168.9.9"}, + }} + srv := httptest.NewServer(f.handler()) + defer srv.Close() + o := &openwrtProvider{url: srv.URL + "/ubus", user: "rollcall", pass: "pw", hc: srv.Client()} + a := &App{dhcpRes: o, dhcpResSt: &dhcpResState{}} + want := map[string]dhcpRes{} + r := dhcpRes{MAC: "52:54:00:00:00:01", IP: "10.214.0.50", Hostname: "fresh01", Machine: "m-1", Subnet: "10.214.0.0/24"} + want[r.key()] = r + rep := dhcpResReport{} + a.dhcpResWith(context.Background(), want, []string{"10.214.0.0/24"}, &rep) + if rep.Error != "" || len(rep.Put) != 1 || f.commits != 1 || f.reloads != 1 { + t.Fatalf("report %+v commits %d reloads %d", rep, f.commits, f.reloads) + } + var mine map[string]any + for _, s := range f.sections { + if s["rollcall"] == "m-1" { + mine = s + } + } + if mine == nil || mine["ip"] != "10.214.0.50" || mine["name"] != "fresh01" { + t.Errorf("sections %v", f.sections) + } + // session expired: logs in again; the machine goes away: its section is deleted, the printer stays + f.sessions = map[string]bool{} + rep = dhcpResReport{} + a.dhcpResWith(context.Background(), map[string]dhcpRes{}, []string{"10.214.0.0/24"}, &rep) + if rep.Error != "" || len(rep.Deleted) != 1 || f.logins != 2 || f.sections["lan_printer"] == nil || f.sections["other_net"] == nil { + t.Errorf("report %+v logins %d sections %v", rep, f.logins, f.sections) + } +} diff --git a/cmd/rollcall/ipam.go b/cmd/rollcall/ipam.go index 2dbfdec..0ac660a 100644 --- a/cmd/rollcall/ipam.go +++ b/cmd/rollcall/ipam.go @@ -238,6 +238,7 @@ func (a *App) claimIP(ctx context.Context, x IPAddr) (bool, error) { ok, err := a.store.ClaimIP(ctx, x) if ok { a.kickDNS() + a.kickDHCPRes() } if a.ipam != nil && (err != nil || !ok) { a.kickIPAM() // the record we just made is reconciled away or re-owned @@ -247,6 +248,7 @@ func (a *App) claimIP(ctx context.Context, x IPAddr) (bool, error) { func (a *App) kickIPAM() { a.kickDNS() // the same address changes move DNS records + a.kickDHCPRes() if a.ipamSt == nil { return } @@ -463,20 +465,30 @@ func newNetBox(c IPAMConfig) (*netBox, error) { if err != nil { return nil, fmt.Errorf("netbox token: %w", err) } + hc, err := httpClientCA(c.CAFile, 30*time.Second) + if err != nil { + return nil, fmt.Errorf("netbox ca_file: %w", err) + } + return &netBox{base: strings.TrimRight(c.URL, "/"), token: strings.TrimSpace(string(tok)), + tag: c.tag(), field: c.field(), client: hc}, nil +} + +// httpClientCA is an HTTP client that trusts caFile (PEM) instead of the +// system's CAs when it is given. +func httpClientCA(caFile string, timeout time.Duration) (*http.Client, error) { tr := http.DefaultTransport.(*http.Transport).Clone() - if c.CAFile != "" { - pem, err := os.ReadFile(c.CAFile) + if caFile != "" { + pem, err := os.ReadFile(caFile) if err != nil { - return nil, fmt.Errorf("netbox ca_file: %w", err) + return nil, err } pool := x509.NewCertPool() if !pool.AppendCertsFromPEM(pem) { - return nil, fmt.Errorf("netbox ca_file %s: no certificates", c.CAFile) + return nil, fmt.Errorf("%s: no certificates", caFile) } tr.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12} } - return &netBox{base: strings.TrimRight(c.URL, "/"), token: strings.TrimSpace(string(tok)), - tag: c.tag(), field: c.field(), client: &http.Client{Transport: tr, Timeout: 30 * time.Second}}, nil + return &http.Client{Transport: tr, Timeout: timeout}, nil } func (n *netBox) Name() string { return "netbox" } diff --git a/cmd/rollcall/main.go b/cmd/rollcall/main.go index 70f9fa7..7be4e1d 100644 --- a/cmd/rollcall/main.go +++ b/cmd/rollcall/main.go @@ -49,6 +49,7 @@ func usage() { rollcall unassign ID rollcall overlay ID [-stage system|runtime] [-extract DIR] rollcall render PROFILE PATH [-stage system|runtime] + rollcall reservations [status] | sync [-dry] fixed leases on the site's DHCP server (Kea, OpenWrt) for proxy-mode VLANs rollcall firmware list | import FILE|URL -vendor V -component C -version X [-models M,..] [-sha256 S] | rm PKG rollcall firmware show MACHINE [-refresh] | update MACHINE|tag:T PKG [-apply OnReset|Immediate] [-reboot] [-method M] [-force] [-watch] rollcall os list | sync [NAME...] [-force] | import NAME FILE [-family F] [-version V] | promote NAME VERSION | rm NAME VERSION @@ -151,7 +152,7 @@ func main() { case "list", "show", "events", "power", "bootdev", "recommission", "delete", "images", "profile", "assign", "unassign", "overlay", "render", "deploy", "release", "jobs", "job", "tag", "tags", "location", - "bmc", "fabric", "vlan", "subnet", "range", "ip", "layout", "net", "controllers", "controller", "dhcp", "ipam", "dns", "admin-cert": + "bmc", "fabric", "vlan", "subnet", "range", "ip", "layout", "net", "controllers", "controller", "dhcp", "ipam", "dns", "reservations", "admin-cert": if err := runClient(cmd, args); err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) diff --git a/cmd/rollcall/netapi.go b/cmd/rollcall/netapi.go index 9cae16a..d6ac23f 100644 --- a/cmd/rollcall/netapi.go +++ b/cmd/rollcall/netapi.go @@ -825,6 +825,66 @@ func (c *client) netCmd(cmd string, args []string) error { return c.ipamCmd(sub, args) case "dns": return c.dnsCmd(sub, args) + case "reservations": + return c.reservationsCmd(sub, args) + } + return nil +} + +func (c *client) reservationsCmd(sub string, args []string) error { + show := func(rep dhcpResReport) { + for _, x := range []struct { + what string + list []string + }{{"put", rep.Put}, {"delete", rep.Deleted}, {"conflict", rep.Conflicts}, {"skipped", rep.Skipped}} { + for _, v := range x.list { + fmt.Printf("%-9s %s\n", x.what, v) + } + } + dry := "" + if rep.DryRun { + dry = " (dry run, nothing changed)" + } + fmt.Printf("%s: %d put, %d deleted, %d conflicts%s\n", orDash(rep.Provider), len(rep.Put), len(rep.Deleted), len(rep.Conflicts), dry) + if rep.Error != "" { + fmt.Println("error:", rep.Error) + } + } + switch sub { + case "", "status": + var st struct { + Provider string `json:"provider"` + URL string `json:"url"` + Last dhcpResReport `json:"last_sync"` + } + if err := c.do("GET", "/api/v1/dhcp-reservations", nil, &st); err != nil { + return err + } + if st.Provider == "" { + fmt.Println("no DHCP reservation provider (dhcp_reservations.provider in config.json: kea or openwrt)") + return nil + } + fmt.Printf("provider %s at %s\n", st.Provider, st.URL) + if !st.Last.At.IsZero() { + fmt.Printf("last sync %s\n", st.Last.At.Local().Format("2006-01-02 15:04:05")) + show(st.Last) + } + case "sync": + fs := newFlags("reservations sync") + dry := fs.Bool("dry", false, "show what would change") + _ = fs.Parse(args[min(1, len(args)):]) + q := "" + if *dry { + q = "?dry=1" + } + var rep dhcpResReport + err := c.do("POST", "/api/v1/dhcp-reservations/sync"+q, nil, &rep) + if rep.Provider != "" || rep.Error != "" { + show(rep) + } + return err + default: + usage() } return nil } diff --git a/cmd/rollcall/server.go b/cmd/rollcall/server.go index e96a42e..e3a2897 100644 --- a/cmd/rollcall/server.go +++ b/cmd/rollcall/server.go @@ -42,6 +42,9 @@ type App struct { dnsp dnsProvider // nil = no direct DNS writes dnsSt *dnsState + // fixed leases on the site's DHCP server (dhcpres.go); nil = none + dhcpRes dhcpResProvider + dhcpResSt *dhcpResState } type apiError struct { @@ -201,6 +204,8 @@ func (a *App) routes() http.Handler { mux.HandleFunc("GET /api/v1/nodes/self/overlay", a.handleNodeOverlay) // node token auth inside // disk-based deployment mux.HandleFunc("GET /api/v1/os", a.adminAuth(a.handleOS)) + mux.HandleFunc("GET /api/v1/dhcp-reservations", a.adminAuth(a.handleDHCPRes)) + mux.HandleFunc("POST /api/v1/dhcp-reservations/sync", a.adminAuth(a.handleDHCPResSync)) mux.HandleFunc("GET /api/v1/firmware", a.adminAuth(a.handleFirmwareRepo)) mux.HandleFunc("POST /api/v1/firmware", a.adminAuth(a.handleFirmwareRepo)) mux.HandleFunc("DELETE /api/v1/firmware/{pkg}", a.adminAuth(a.handleFirmwareRemove)) @@ -844,6 +849,9 @@ func serve(cfgPath string) error { if err := app.startIPAM(); err != nil { return fmt.Errorf("ipam: %w", err) } + if err := app.startDHCPRes(); err != nil { + return err + } if err := app.startDNS(); err != nil { return fmt.Errorf("dns: %w", err) }