DHCP reservations on the site's DHCP server: Kea and OpenWrt
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:
1 parent
b2ad0210bf
commit
482a9d5987
10 files changed
+1124
-8
No files matched your search
@@ -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)
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
@@ -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" }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user