DHCP reservations on the site's DHCP server: Kea and OpenWrt
CI / agent (push) Successful in 3s
CI / site (push) Successful in 3s
CI / go (push) Successful in 43s

On a VLAN in proxy mode the site's DHCP server hands out addresses and
Rollcall answers only PXE. With dhcp_reservations in config.json the
machines still get the addresses Rollcall planned: each address on a
proxy-mode IPv4 subnet becomes a fixed lease (MAC of its interface ->
IP, hostname) on that server, reconciled on every address change and
every sync_interval.

- kea: control API (Kea 3.0's HTTP socket or the Control Agent; the
  "service" field is learned) with the host_cmds hook, open source
  since Kea 3.0: reservation-add/-del/-get-all, subnet ids from
  config-get, ownership in user-context {"rollcall": machine},
  config-write afterwards (persist) for reservations kept in memory;
  a clear error without host_cmds (Debian's Kea 2.6 lacks it)
- openwrt: rpcd/ubus JSON-RPC, host sections in /etc/config/dhcp with
  option rollcall, uci commit and dnsmasq reload; relogin on expiry
- reservations others made are never touched; one holding the MAC or
  the address is a conflict

rollcall reservations [status] | sync [-dry]; API under
/api/v1/dhcp-reservations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-30 13:46:44 +01:00
1 parent b2ad0210bf
commit 482a9d5987
10 files changed
+1124 -8

No files matched your search

+1 -1
View File
@@ -289,7 +289,7 @@ func runClient(cmd string, args []string) error {
return c.locationCmd(args) return c.locationCmd(args)
case "bmc": case "bmc":
return c.bmcCmd(args) 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) return c.netCmd(cmd, args)
case "admin-cert": case "admin-cert":
return c.adminCertCmd(args) return c.adminCertCmd(args)
+2
View File
@@ -57,6 +57,8 @@ type Config struct {
// DNS records for machines written directly (PowerDNS API or RFC 2136) // DNS records for machines written directly (PowerDNS API or RFC 2136)
DNS DNSConfig `json:"dns"` 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) // external IPAM authority (NetBox; Infoblox at sites that run it)
IPAM IPAMConfig `json:"ipam"` IPAM IPAMConfig `json:"ipam"`
+335
View File
@@ -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": <machine>}
// openwrt rpcd/ubus: "host" sections in /etc/config/dhcp with an
// option rollcall '<machine>'
//
// 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"))
}
+217
View File
@@ -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": <machine>};
// 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
}
+204
View File
@@ -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 '<machine>'. 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
}
+277
View File
@@ -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)
}
}
+18 -6
View File
@@ -238,6 +238,7 @@ func (a *App) claimIP(ctx context.Context, x IPAddr) (bool, error) {
ok, err := a.store.ClaimIP(ctx, x) ok, err := a.store.ClaimIP(ctx, x)
if ok { if ok {
a.kickDNS() a.kickDNS()
a.kickDHCPRes()
} }
if a.ipam != nil && (err != nil || !ok) { if a.ipam != nil && (err != nil || !ok) {
a.kickIPAM() // the record we just made is reconciled away or re-owned 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() { func (a *App) kickIPAM() {
a.kickDNS() // the same address changes move DNS records a.kickDNS() // the same address changes move DNS records
a.kickDHCPRes()
if a.ipamSt == nil { if a.ipamSt == nil {
return return
} }
@@ -463,20 +465,30 @@ func newNetBox(c IPAMConfig) (*netBox, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("netbox token: %w", err) 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() tr := http.DefaultTransport.(*http.Transport).Clone()
if c.CAFile != "" { if caFile != "" {
pem, err := os.ReadFile(c.CAFile) pem, err := os.ReadFile(caFile)
if err != nil { if err != nil {
return nil, fmt.Errorf("netbox ca_file: %w", err) return nil, err
} }
pool := x509.NewCertPool() pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) { 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} tr.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}
} }
return &netBox{base: strings.TrimRight(c.URL, "/"), token: strings.TrimSpace(string(tok)), return &http.Client{Transport: tr, Timeout: timeout}, nil
tag: c.tag(), field: c.field(), client: &http.Client{Transport: tr, Timeout: 30 * time.Second}}, nil
} }
func (n *netBox) Name() string { return "netbox" } func (n *netBox) Name() string { return "netbox" }
+2 -1
View File
@@ -49,6 +49,7 @@ func usage() {
rollcall unassign ID rollcall unassign ID
rollcall overlay ID [-stage system|runtime] [-extract DIR] rollcall overlay ID [-stage system|runtime] [-extract DIR]
rollcall render PROFILE PATH [-stage system|runtime] 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 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 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 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", case "list", "show", "events", "power", "bootdev", "recommission", "delete",
"images", "profile", "assign", "unassign", "overlay", "render", "images", "profile", "assign", "unassign", "overlay", "render",
"deploy", "release", "jobs", "job", "tag", "tags", "location", "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 { if err := runClient(cmd, args); err != nil {
fmt.Fprintln(os.Stderr, "error:", err) fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1) os.Exit(1)
+60
View File
@@ -825,6 +825,66 @@ func (c *client) netCmd(cmd string, args []string) error {
return c.ipamCmd(sub, args) return c.ipamCmd(sub, args)
case "dns": case "dns":
return c.dnsCmd(sub, args) 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 return nil
} }
+8
View File
@@ -42,6 +42,9 @@ type App struct {
dnsp dnsProvider // nil = no direct DNS writes dnsp dnsProvider // nil = no direct DNS writes
dnsSt *dnsState dnsSt *dnsState
// fixed leases on the site's DHCP server (dhcpres.go); nil = none
dhcpRes dhcpResProvider
dhcpResSt *dhcpResState
} }
type apiError struct { 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 mux.HandleFunc("GET /api/v1/nodes/self/overlay", a.handleNodeOverlay) // node token auth inside
// disk-based deployment // disk-based deployment
mux.HandleFunc("GET /api/v1/os", a.adminAuth(a.handleOS)) 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("GET /api/v1/firmware", a.adminAuth(a.handleFirmwareRepo))
mux.HandleFunc("POST /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)) 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 { if err := app.startIPAM(); err != nil {
return fmt.Errorf("ipam: %w", err) return fmt.Errorf("ipam: %w", err)
} }
if err := app.startDHCPRes(); err != nil {
return err
}
if err := app.startDNS(); err != nil { if err := app.startDNS(); err != nil {
return fmt.Errorf("dns: %w", err) return fmt.Errorf("dns: %w", err)
} }