Infoblox WAPI driver for the IPAM authority
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 41s

ipam.provider infoblox: one record:host per address, named by its DNS
name and configured for DNS in dns_view ("-" = IPAM only); ownership is
the rollcall_machine extensible attribute, changed with extattrs+ so a
host's other attributes stay. Anything else on the address (a host, a
lease, a fixed address, a DHCP or reserved range, network/broadcast) makes
it taken. Delegated networks carry the rollcall attribute. WAPI paging.

The reconcile no longer compares fields an authority does not keep
(Infoblox has no prefix length or status on a host). Built from the WAPI
reference and tested against a fake WAPI; no real grid yet.

Closes #11

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-10-30 23:22:17 +01:00
1 parent eb2b7baa9f
commit 008fa3f66f
7 files changed
+731 -9

No files matched your search

+4
View File
@@ -18,6 +18,10 @@ publish a tag that has no section here.
role, status, interfaces with MAC objects, assigned addresses and primary IP. Site, location and
rack are matched by name from the machine's location and never created; ownership by tag and
`rollcall_machine` as for addresses, foreign devices of the same name are conflicts.
- Infoblox WAPI driver for the IPAM authority (`ipam.provider: infoblox`): one host record per
address (DNS-published in `dns_view`, or IPAM-only), ownership by the `rollcall_machine` extensible
attribute (kept with `extattrs+`), leases, fixed addresses and ranges count as taken, delegated
networks by the `rollcall` attribute. Not tested against a real grid yet.
- Lab NetBox (`lab/netbox/install.sh`): a container on the lab segment for the NetBox drivers.
- Fixed leases on the site's DHCP server for proxy-mode VLANs (`dhcp_reservations`), for Kea with
`host_cmds` (Kea 3.0+) and OpenWrt (rpcd). Rollcall's own leases are reconciled; foreign ones are
+36 -3
View File
@@ -744,7 +744,7 @@ Verified in the lab: Debian 13 (with a bond and VLAN), Rocky 9 and AlmaLinux 9
dual-stack on two segments, AAAA lookups through dnsmasq, the admin CLI over
`https://[fdc3:...]`.
## External IPAM (NetBox; Infoblox-style)
## External IPAM (NetBox, Infoblox)
Rollcall works on its own, but at a site with an established network plan the
address authority is somewhere else. With `ipam` in `config.json` every
@@ -822,9 +822,42 @@ The NetBox user needs `view/add/change/delete` on `dcim.device`,
`dcim.devicetype` and `dcim.devicerole`, and `view` on `dcim.location` and
`dcim.rack`, on top of the IPAM permissions.
### Infoblox
The same contract over Infoblox WAPI (v2.12 by default; give a full
`.../wapi/vX.Y` URL for another):
```json
"ipam": {
"provider": "infoblox",
"url": "https://gm.example.com",
"user": "rollcall",
"password_file": "/etc/rollcall/infoblox.pw",
"ca_file": "/etc/rollcall/infoblox-ca.pem",
"dns_zone": "int.example.com",
"network_view": "default",
"dns_view": "default"
}
```
- **One host record per address.** Named by its DNS name (`rollcall-<address>`
without one) and configured for DNS in `dns_view`, so Infoblox publishes the
A/AAAA record itself; `"dns_view": "-"` keeps them IPAM-only. The zone must
exist in that view.
- **Ownership** is the extensible attribute `rollcall_machine` (define it in
the grid as String). Rollcall changes it with `extattrs+`, so a host's other
attributes stay; hosts without it are never changed or deleted.
- **Taken** is anything else on the address: another host, a lease, a fixed
address, a DHCP or reserved range, the network or broadcast address.
- **Delegated networks** carry the extensible attribute `rollcall` (any value;
define it as String); `Site` and `VLAN` attributes are shown when present.
- The WAPI user needs read/write on host records and read on networks and
IPv4/IPv6 addresses in the network view (and the DNS view when publishing).
Device sync (above) is NetBox-only.
The provider is an interface (reserve, owned records, update, delete,
delegated prefixes); the NetBox driver is the first. An Infoblox WAPI driver
maps them to `record:host`/`fixedaddress` with extensible attributes.
delegated prefixes) with the NetBox and Infoblox drivers.
## DNS provider (PowerDNS, RFC 2136)
+334
View File
@@ -0,0 +1,334 @@
package main
// Infoblox WAPI driver for the IPAM authority. Each Rollcall address is one
// record:host holding that address, named by its DNS name (rollcall-<address>
// without one) and configured for DNS when it has a name and dns_view is not
// "-" (Infoblox then publishes the A/AAAA record itself). Ownership is the extensible
// attribute machine_field (default rollcall_machine), which must be defined
// in the grid (String). Delegated networks carry the extensible attribute
// named by tag (default rollcall). An address is taken when anything that is
// not Rollcall's host uses it: a lease, a fixed address, a DHCP or reserved
// range, another host, the network or broadcast address.
//
// Documentation: Infoblox WAPI reference (v2.12): record:host, ipv4address,
// ipv6address, network, ipv6network; extensible attribute search (*EA~=),
// "extattrs+" to change attributes without dropping others, paging
// (_paging, _return_as_object, _page_id).
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/netip"
"net/url"
"os"
"strconv"
"strings"
"time"
)
type infoblox struct {
base string // https://gm.example.com/wapi/v2.12
user, pass string
field, tag string
networkView string
dnsView string // "" = no DNS (IPAM-only hosts)
client *http.Client
}
func newInfoblox(c IPAMConfig) (*infoblox, error) {
if c.URL == "" || c.User == "" || c.PasswordFile == "" {
return nil, fmt.Errorf("infoblox: url, user and password_file are required")
}
pw, err := os.ReadFile(c.PasswordFile)
if err != nil {
return nil, fmt.Errorf("infoblox password: %w", err)
}
hc, err := httpClientCA(c.CAFile, 30*time.Second)
if err != nil {
return nil, fmt.Errorf("infoblox ca_file: %w", err)
}
base := strings.TrimRight(c.URL, "/")
if !strings.Contains(base, "/wapi/") {
base += "/wapi/v2.12"
}
dv := orDefault(c.DNSView, "default")
if dv == "-" {
dv = ""
}
return &infoblox{base: base, user: c.User, pass: strings.TrimSpace(string(pw)), field: c.field(), tag: c.tag(),
networkView: orDefault(c.NetworkView, "default"), dnsView: dv, client: hc}, nil
}
func (ib *infoblox) Name() string { return "infoblox" }
type ibError struct {
Code int
Body string
}
func (e *ibError) Error() string {
var x struct{ Text, Code string }
if json.Unmarshal([]byte(e.Body), &x) == nil && x.Text != "" {
return fmt.Sprintf("HTTP %d: %s", e.Code, trunc(x.Text, 300))
}
return fmt.Sprintf("HTTP %d: %s", e.Code, trunc(e.Body, 300))
}
// do calls WAPI; path is an object type or reference with its query.
func (ib *infoblox) do(ctx context.Context, method, path string, q url.Values, body, out any) error {
var rd io.Reader
if body != nil {
b, _ := json.Marshal(body)
rd = bytes.NewReader(b)
}
u := ib.base + "/" + strings.TrimPrefix(path, "/")
if len(q) > 0 {
u += "?" + q.Encode()
}
req, err := http.NewRequestWithContext(ctx, method, u, rd)
if err != nil {
return err
}
req.SetBasicAuth(ib.user, ib.pass)
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
resp, err := ib.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
if resp.StatusCode >= 300 {
return &ibError{resp.StatusCode, string(b)}
}
if out != nil && len(b) > 0 {
return json.Unmarshal(b, out)
}
return nil
}
// all reads every object of a search through WAPI paging.
func (ib *infoblox) all(ctx context.Context, objType string, q url.Values) ([]obj, error) {
q.Set("_paging", "1")
q.Set("_return_as_object", "1")
q.Set("_max_results", "1000")
var out []obj
for {
var p struct {
Result []obj `json:"result"`
NextPageID string `json:"next_page_id"`
}
if err := ib.do(ctx, "GET", objType, q, nil, &p); err != nil {
return nil, err
}
out = append(out, p.Result...)
if p.NextPageID == "" {
return out, nil
}
q = url.Values{"_page_id": {p.NextPageID}}
}
}
const ibHostFields = "name,comment,extattrs,configure_for_dns,ipv4addrs,ipv6addrs,view"
func ibIs6(ip string) bool {
a, ok := parseAddr(ip)
return ok && a.Is6()
}
// owner is the machine of a host record, "" when it is not Rollcall's.
func (ib *infoblox) owner(h obj) string {
return str(asObj(asObj(h["extattrs"])[ib.field])["value"])
}
func (ib *infoblox) hostIP(h obj) string {
for _, k := range []string{"ipv4addrs", "ipv6addrs"} {
for _, a := range toList(h[k]) {
for _, f := range []string{"ipv4addr", "ipv6addr"} {
if s := str(asObj(a)[f]); s != "" {
if p, ok := parseAddr(s); ok {
return p.String()
}
}
}
}
}
return ""
}
func (ib *infoblox) toExt(h obj) extAddr {
e := extAddr{ID: str(h["_ref"]), IP: ib.hostIP(h), Machine: ib.owner(h), Desc: str(h["comment"])}
if n := strings.ToLower(str(h["name"])); n != ibHostName(e) {
e.DNSName = n
}
return e // Bits and Status are not kept by Infoblox (0 and "": not compared)
}
func ibHostName(a extAddr) string {
return "rollcall-" + strings.NewReplacer(".", "-", ":", "-").Replace(a.IP)
}
func (ib *infoblox) hostBody(a extAddr, create bool) obj {
dns := a.DNSName != "" && ib.dnsView != ""
// the name is the DNS name even when Infoblox does not publish it (IPAM only)
b := obj{"name": orDefault(a.DNSName, ibHostName(a)), "comment": trunc(a.Desc, 256), "configure_for_dns": dns}
if create {
addr := "ipv4addr"
if ibIs6(a.IP) {
addr = "ipv6addr"
}
b[addr+"s"] = []obj{{addr: a.IP, "configure_for_dhcp": false}}
b["extattrs"] = obj{ib.field: obj{"value": a.Machine}}
b["network_view"] = ib.networkView
if dns {
b["view"] = ib.dnsView
}
} else {
b["extattrs+"] = obj{ib.field: obj{"value": a.Machine}} // other attributes stay
}
return b
}
func (ib *infoblox) hostsAt(ctx context.Context, ip string) ([]obj, error) {
k := "ipv4addr"
if ibIs6(ip) {
k = "ipv6addr"
}
return ib.all(ctx, "record:host", url.Values{k: {ip}, "network_view": {ib.networkView}, "_return_fields": {ibHostFields}})
}
// used: references of whatever uses ip (empty when Infoblox has no network for it).
func (ib *infoblox) used(ctx context.Context, ip string) (status string, refs []string, types []string, err error) {
t := "ipv4address"
if ibIs6(ip) {
t = "ipv6address"
}
var res []obj
err = ib.do(ctx, "GET", t, url.Values{"ip_address": {ip}, "network_view": {ib.networkView}, "_return_fields": {"status,types,objects"}}, nil, &res)
if err != nil || len(res) == 0 {
return "", nil, nil, err
}
for _, r := range toList(res[0]["objects"]) {
refs = append(refs, str(r))
}
for _, x := range toList(res[0]["types"]) {
types = append(types, str(x))
}
return str(res[0]["status"]), refs, types, nil
}
func (ib *infoblox) Reserve(ctx context.Context, a extAddr) (bool, error) {
hosts, err := ib.hostsAt(ctx, a.IP)
if err != nil {
return false, err
}
mine := map[string]bool{}
var cur obj
for _, h := range hosts {
if ib.owner(h) == "" {
return true, nil // someone else's host
}
mine[str(h["_ref"])] = true
cur = h
}
status, refs, types, err := ib.used(ctx, a.IP)
if err != nil {
return false, err
}
for _, t := range types {
switch t {
case "NETWORK", "BROADCAST", "DHCP_RANGE", "RESERVED_RANGE":
return true, nil
}
}
others := 0
for _, r := range refs {
if !mine[r] {
others++
}
}
if others > 0 || (status == "USED" && len(refs) == 0 && len(mine) == 0) {
return true, nil // a lease, a fixed address, a discovered or conflicting use
}
if cur != nil {
e := ib.toExt(cur)
a.ID = e.ID
if e.DNSName != a.DNSName || e.Machine != a.Machine || e.Desc != a.Desc {
return false, ib.Update(ctx, a)
}
return false, nil
}
return false, ib.do(ctx, "POST", "record:host", nil, ib.hostBody(a, true), nil)
}
func (ib *infoblox) Owned(ctx context.Context) ([]extAddr, error) {
hs, err := ib.all(ctx, "record:host", url.Values{"*" + ib.field + "~": {"."}, "network_view": {ib.networkView}, "_return_fields": {ibHostFields}})
if err != nil {
return nil, err
}
out := []extAddr{}
for _, h := range hs {
if e := ib.toExt(h); e.Machine != "" && e.IP != "" {
out = append(out, e)
}
}
return out, nil
}
// Update renames, re-owns and re-describes a host; switching DNS on or off
// re-creates it (a host's DNS view is fixed at creation).
func (ib *infoblox) Update(ctx context.Context, a extAddr) error {
var cur obj
if err := ib.do(ctx, "GET", a.ID, url.Values{"_return_fields": {ibHostFields}}, nil, &cur); err != nil {
return err
}
if ib.owner(cur) == "" {
return fmt.Errorf("%s is no longer Rollcall's", a.IP)
}
if (cur["configure_for_dns"] == true) != (a.DNSName != "" && ib.dnsView != "") {
if err := ib.do(ctx, "DELETE", a.ID, nil, nil, nil); err != nil {
return err
}
return ib.do(ctx, "POST", "record:host", nil, ib.hostBody(a, true), nil)
}
return ib.do(ctx, "PUT", a.ID, nil, ib.hostBody(a, false), nil)
}
func (ib *infoblox) Delete(ctx context.Context, a extAddr) error {
var cur obj // re-read: only delete what is still ours
if err := ib.do(ctx, "GET", a.ID, url.Values{"_return_fields": {ibHostFields}}, nil, &cur); err != nil {
if e, ok := err.(*ibError); ok && (e.Code == 404 || e.Code == 400) {
return nil // gone (WAPI answers 400 for a reference that no longer exists)
}
return err
}
if ib.owner(cur) == "" {
return nil
}
return ib.do(ctx, "DELETE", a.ID, nil, nil, nil)
}
func (ib *infoblox) Prefixes(ctx context.Context) ([]extPrefix, error) {
out := []extPrefix{}
for _, t := range []string{"network", "ipv6network"} {
ns, err := ib.all(ctx, t, url.Values{"*" + ib.tag + "~": {"."}, "network_view": {ib.networkView}, "_return_fields": {"network,comment,extattrs"}})
if err != nil {
return nil, err
}
for _, n := range ns {
p, err := netip.ParsePrefix(str(n["network"]))
if err != nil {
continue
}
ea := asObj(n["extattrs"])
e := extPrefix{Prefix: p.Masked().String(), Desc: str(n["comment"]), Site: str(asObj(ea["Site"])["value"])}
e.VID, _ = strconv.Atoi(fmt.Sprint(asObj(ea["VLAN"])["value"]))
out = append(out, e)
}
}
return out, nil
}
+336
View File
@@ -0,0 +1,336 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"testing"
)
// fakeWAPI implements the slice of Infoblox WAPI v2.12 the driver uses:
// record:host (search, create, read, update with extattrs+, delete), address
// usage (ipv4address/ipv6address), networks and paging.
type fakeWAPI struct {
mu sync.Mutex
next int
hosts map[string]map[string]any // ref -> host
uses map[string][]string // ip -> other objects using it (leases, fixed addresses)
types map[string][]string // ip -> address types (DHCP_RANGE, ...)
networks []map[string]any
pages map[string][]any
writes []string
}
func newFakeWAPI() *fakeWAPI {
return &fakeWAPI{hosts: map[string]map[string]any{}, uses: map[string][]string{}, types: map[string][]string{}, pages: map[string][]any{}}
}
func (f *fakeWAPI) host(name, ip string, ea map[string]any, dns bool) string {
f.next++
ref := fmt.Sprintf("record:host/ZG5zLmhvc3Qk%d:%s/default", f.next, name)
k := "ipv4addrs"
addr := map[string]any{"ipv4addr": ip}
if strings.Contains(ip, ":") {
k, addr = "ipv6addrs", map[string]any{"ipv6addr": ip}
}
eas := map[string]any{}
for n, v := range ea {
eas[n] = map[string]any{"value": v}
}
f.hosts[ref] = map[string]any{"_ref": ref, "name": name, "comment": "", "configure_for_dns": dns, k: []any{addr}, "extattrs": eas}
return ref
}
func hostIPs(h map[string]any) []string {
var out []string
for _, k := range []string{"ipv4addrs", "ipv6addrs"} {
for _, a := range toList(h[k]) {
out = append(out, str(asObj(a)["ipv4addr"])+str(asObj(a)["ipv6addr"]))
}
}
return out
}
func (f *fakeWAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
write := func(code int, v any) { w.WriteHeader(code); _ = json.NewEncoder(w).Encode(v) }
if u, p, ok := r.BasicAuth(); !ok || u != "rollcall" || p != "s3cret" {
write(401, map[string]any{"text": "Authorization Required"})
return
}
path := strings.TrimPrefix(r.URL.Path, "/wapi/v2.12/")
q := r.URL.Query()
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if r.Method != "GET" {
f.writes = append(f.writes, r.Method+" "+path)
}
page := func(res []any) {
if id := q.Get("_page_id"); id != "" {
res = f.pages[id]
}
const size = 2 // small pages so paging is exercised
out := map[string]any{"result": res}
if len(res) > size {
id := strconv.Itoa(len(f.pages) + 1)
f.pages[id] = res[size:]
out = map[string]any{"result": res[:size], "next_page_id": id}
}
write(200, out)
}
switch {
case path == "record:host" && r.Method == "GET":
refs := []string{}
for ref := range f.hosts {
refs = append(refs, ref)
}
sort.Strings(refs)
res := []any{}
for _, ref := range refs {
h := f.hosts[ref]
ok := true
for k, v := range q {
switch {
case k == "ipv4addr" || k == "ipv6addr":
ok = ok && strings.Contains(" "+strings.Join(hostIPs(h), " ")+" ", " "+v[0]+" ")
case strings.HasPrefix(k, "*") && strings.HasSuffix(k, "~"):
_, has := asObj(h["extattrs"])[strings.TrimSuffix(k[1:], "~")]
ok = ok && has
}
}
if ok {
res = append(res, h)
}
}
page(res)
case path == "record:host" && r.Method == "POST":
ea := map[string]any{}
for k, v := range asObj(body["extattrs"]) {
ea[k] = asObj(v)["value"]
}
ip := ""
if a := toList(body["ipv4addrs"]); len(a) > 0 {
ip = str(asObj(a[0])["ipv4addr"])
} else if a := toList(body["ipv6addrs"]); len(a) > 0 {
ip = str(asObj(a[0])["ipv6addr"])
}
if body["configure_for_dns"] == true && body["view"] != "default" {
write(400, map[string]any{"Error": "AdmConDataNotFoundError", "code": "Client.Ibap.Data.NotFound", "text": "view not found"})
return
}
ref := f.host(str(body["name"]), ip, ea, body["configure_for_dns"] == true)
f.hosts[ref]["comment"] = body["comment"]
write(201, ref)
case path == "ipv4address" || path == "ipv6address":
ip := q.Get("ip_address")
objs := append([]string{}, f.uses[ip]...)
for ref, h := range f.hosts {
for _, x := range hostIPs(h) {
if x == ip {
objs = append(objs, ref)
}
}
}
status := "UNUSED"
if len(objs) > 0 {
status = "USED"
}
write(200, []any{map[string]any{"ip_address": ip, "status": status, "objects": objs, "types": f.types[ip]}})
case path == "network" || path == "ipv6network":
res := []any{}
for _, n := range f.networks {
if strings.Contains(str(n["network"]), ":") == (path == "ipv6network") {
res = append(res, n)
}
}
page(res)
case strings.HasPrefix(path, "record:host/"):
h, ok := f.hosts[path]
if !ok {
write(400, map[string]any{"Error": "AdmConProtoError: Unknown object", "text": "Unknown object"})
return
}
switch r.Method {
case "GET":
write(200, h)
case "PUT":
for k, v := range body {
if k == "extattrs+" {
for n, x := range asObj(v) {
asObj(h["extattrs"])[n] = x
}
continue
}
h[k] = v
}
write(200, path)
case "DELETE":
delete(f.hosts, path)
write(200, path)
}
default:
write(400, map[string]any{"text": "unknown " + path})
}
}
func testInfoblox(t *testing.T, dnsView string) (*App, *fakeWAPI) {
t.Helper()
f := newFakeWAPI()
srv := httptest.NewServer(f)
t.Cleanup(srv.Close)
pw := filepath.Join(t.TempDir(), "pw")
if err := os.WriteFile(pw, []byte("s3cret\n"), 0o600); err != nil {
t.Fatal(err)
}
cfg := &Config{IPAM: IPAMConfig{Provider: "infoblox", URL: srv.URL, User: "rollcall", PasswordFile: pw, DNSZone: "int.example", DNSView: dnsView}}
p, err := newIPAMProvider(cfg.IPAM)
if err != nil {
t.Fatal(err)
}
a := &App{cfg: cfg, ipam: p, ipamSt: &ipamState{kick: make(chan struct{}, 1)}}
a.subnets = []Subnet{{CIDR: "10.213.77.0/24", Purpose: "provisioning"}, {CIDR: "fd00:213:77::/64", Purpose: "provisioning"}}
return a, f
}
func TestInfobloxReserve(t *testing.T) {
a, f := testInfoblox(t, "")
ctx := context.Background()
f.host("printer.int.example", "10.213.77.20", nil, true) // someone else's host
f.uses["10.213.77.21"] = []string{"lease/ZG5zLmxlYXNl:10.213.77.21/default"} // a lease
f.types["10.213.77.22"] = []string{"DHCP_RANGE"} // inside a DHCP range
mine := f.host("old.int.example", "10.213.77.23", map[string]any{"rollcall_machine": "m-1", "Owner": "ops"}, true)
f.uses["10.213.77.24"] = []string{"fixedaddress/ZG5z:10.213.77.24/default"} // a fixed address
for ip, want := range map[string]bool{"10.213.77.20": true, "10.213.77.21": true, "10.213.77.22": true, "10.213.77.23": false, "10.213.77.24": true, "10.213.77.25": false} {
taken, err := a.ipam.Reserve(ctx, a.extFor(IPAddr{IP: ip, Subnet: "10.213.77.0/24", MachineID: "m-2", Hostname: "node002", Iface: "eth0"}, &a.subnets[0]))
if err != nil {
t.Fatal(ip, err)
}
if taken != want {
t.Errorf("%s: taken=%v want %v", ip, taken, want)
}
}
h := f.hosts[mine]
if h["name"] != "node002.int.example" || asObj(asObj(h["extattrs"])["rollcall_machine"])["value"] != "m-2" ||
asObj(asObj(h["extattrs"])["Owner"])["value"] != "ops" {
t.Errorf("re-owned host %v (other extensible attributes must stay)", h)
}
var made map[string]any
for _, x := range f.hosts {
if hostIPs(x)[0] == "10.213.77.25" {
made = x
}
}
if made == nil || made["name"] != "node002.int.example" || made["configure_for_dns"] != true || made["comment"] != "rollcall m-2 eth0" {
t.Fatalf("new host %v", made)
}
// v6 and a name-less reservation
if _, err := a.ipam.Reserve(ctx, a.extFor(IPAddr{IP: "fd00:213:77::50", Subnet: "fd00:213:77::/64", Kind: "reserved", Hostname: "core switch"}, &a.subnets[1])); err != nil {
t.Fatal(err)
}
var v6 map[string]any
for _, x := range f.hosts {
if hostIPs(x)[0] == "fd00:213:77::50" {
v6 = x
}
}
if v6 == nil || v6["name"] != "rollcall-fd00-213-77--50" || v6["configure_for_dns"] != false {
t.Fatalf("v6 reservation %v", v6)
}
}
func TestInfobloxReconcile(t *testing.T) {
a, f := testInfoblox(t, "")
ctx := context.Background()
foreign := f.host("printer.int.example", "10.213.77.20", nil, true)
f.host("gone.int.example", "10.213.77.30", map[string]any{"rollcall_machine": "m-9"}, true)
f.host("wrong.int.example", "10.213.77.50", map[string]any{"rollcall_machine": "m-1"}, true)
f.host("a.int.example", "10.213.77.60", map[string]any{"rollcall_machine": "m-5"}, true)
f.host("b.int.example", "10.213.77.61", map[string]any{"rollcall_machine": "m-5"}, true) // paging: 5 hosts, pages of 2
local := []IPAddr{
{IP: "10.213.77.20", Subnet: "10.213.77.0/24", MachineID: "m-2", Hostname: "node002", Kind: "auto"},
{IP: "10.213.77.50", Subnet: "10.213.77.0/24", MachineID: "m-1", Hostname: "node001", Kind: "auto"},
{IP: "10.213.77.51", Subnet: "10.213.77.0/24", MachineID: "m-4", Hostname: "node004", Kind: "static"},
{IP: "10.213.77.60", Subnet: "10.213.77.0/24", MachineID: "m-5", Hostname: "a", Kind: "auto"},
{IP: "10.213.77.61", Subnet: "10.213.77.0/24", MachineID: "m-5", Hostname: "b", Kind: "auto"},
}
for _, x := range f.hosts { // descriptions as Rollcall writes them
if m := ib(a).owner(x); m != "" {
x["comment"] = "rollcall " + m
}
}
rep := ipamReport{}
a.reconcileAddrs(ctx, local, &rep)
if rep.Error != "" {
t.Fatal(rep.Error)
}
if fmt.Sprint(rep.Created, rep.Updated, rep.Deleted, len(rep.Conflicts)) != "[10.213.77.51] [10.213.77.50] [10.213.77.30] 1" {
t.Errorf("report: %+v", rep)
}
if f.hosts[foreign]["name"] != "printer.int.example" {
t.Error("foreign host touched")
}
f.writes = nil
again := ipamReport{}
a.reconcileAddrs(ctx, local, &again)
if len(f.writes) != 0 || again.Error != "" {
t.Errorf("second pass wrote %v (%+v): bits and status are not kept by Infoblox and must not count", f.writes, again)
}
// the delete re-checks ownership
for ref, x := range f.hosts {
if x["name"] == "node004.int.example" {
delete(asObj(x["extattrs"]), "rollcall_machine")
if err := a.ipam.Delete(ctx, extAddr{ID: ref}); err != nil || f.hosts[ref] == nil {
t.Fatalf("deleted a host that is no longer Rollcall's: %v", err)
}
}
}
if err := a.ipam.Delete(ctx, extAddr{ID: "record:host/gone:x/default"}); err != nil {
t.Errorf("deleting a vanished host: %v", err)
}
}
func TestInfobloxDNSView(t *testing.T) {
a, f := testInfoblox(t, "internal")
_, err := a.ipam.Reserve(context.Background(), a.extFor(IPAddr{IP: "10.213.77.40", Subnet: "10.213.77.0/24", MachineID: "m-1", Hostname: "n1"}, &a.subnets[0]))
if err == nil || !strings.Contains(err.Error(), "view not found") {
t.Fatalf("WAPI error not passed on: %v", err)
}
if len(f.hosts) != 0 {
t.Fatal("host made")
}
}
func TestInfobloxPrefixes(t *testing.T) {
a, f := testInfoblox(t, "")
f.networks = []map[string]any{
{"network": "10.213.80.0/24", "comment": "new hall", "extattrs": map[string]any{"Site": map[string]any{"value": "DC1"}, "VLAN": map[string]any{"value": "300"}}},
{"network": "fd00:213:80::/64", "comment": "new hall v6", "extattrs": map[string]any{}},
}
ps, err := a.ipam.Prefixes(context.Background())
if err != nil {
t.Fatal(err)
}
if fmt.Sprint(ps) != "[{10.213.80.0/24 new hall 300 DC1 false } {fd00:213:80::/64 new hall v6 0 false }]" {
t.Errorf("%v", ps)
}
}
func TestInfobloxAuth(t *testing.T) {
a, _ := testInfoblox(t, "")
ib(a).pass = "wrong"
_, err := a.ipam.Owned(context.Background())
if err == nil || !strings.Contains(err.Error(), "401") {
t.Fatalf("%v", err)
}
}
func ib(a *App) *infoblox { return a.ipam.(*infoblox) }
+14 -3
View File
@@ -37,9 +37,13 @@ import (
)
type IPAMConfig struct {
Provider string `json:"provider"` // "" = built-in only | netbox
Provider string `json:"provider"` // "" = built-in only | netbox | infoblox
URL string `json:"url"` // https://netbox.example
TokenFile string `json:"token_file"` // API token (NetBox v2 "nbt_..." or v1)
User string `json:"user"` // Infoblox WAPI user
PasswordFile string `json:"password_file"` // Infoblox WAPI password
NetworkView string `json:"network_view"` // Infoblox, default "default"
DNSView string `json:"dns_view"` // Infoblox host records' DNS view, default "default"; "-" = IPAM only
CAFile string `json:"ca_file"` // CA that signed the server certificate ("" = system roots)
DNSZone string `json:"dns_zone"` // dns_name = <host>.<zone> unless the subnet has a domain
Tag string `json:"tag"` // ownership tag, default "rollcall"
@@ -120,8 +124,10 @@ func newIPAMProvider(c IPAMConfig) (ipamProvider, error) {
return nil, nil
case "netbox":
return newNetBox(c)
case "infoblox":
return newInfoblox(c)
default:
return nil, fmt.Errorf("ipam provider %q: unknown (netbox)", c.Provider)
return nil, fmt.Errorf("ipam provider %q: unknown (netbox, infoblox)", c.Provider)
}
}
@@ -268,6 +274,9 @@ func (a *App) startIPAM() error {
}
a.ipam = p
a.ipamSt = &ipamState{kick: make(chan struct{}, 1)}
if _, nb := p.(*netBox); a.cfg.IPAM.DeviceSync != nil && !nb {
log.Printf("ipam: device_sync needs the netbox provider; ignored for %s", p.Name())
}
every := 5 * time.Minute
if d, err := time.ParseDuration(a.cfg.IPAM.SyncInterval); err == nil && d >= 10*time.Second {
every = d
@@ -346,7 +355,9 @@ func (a *App) reconcileAddrs(ctx context.Context, local []IPAddr, rep *ipamRepor
want := a.extFor(x, subnetByRef(subnets, x.Subnet))
if o, ok := have[x.IP]; ok {
delete(have, x.IP)
if o.DNSName != want.DNSName || o.Machine != want.Machine || o.Status != want.Status || o.Bits != want.Bits || o.Desc != want.Desc {
// an authority that does not keep a field answers it empty (Infoblox: bits, status)
if o.DNSName != want.DNSName || o.Machine != want.Machine || (o.Status != "" && o.Status != want.Status) ||
(o.Bits != 0 && o.Bits != want.Bits) || o.Desc != want.Desc {
want.ID = o.ID
rep.Updated = append(rep.Updated, x.IP)
if !dry {
+1 -1
View File
@@ -69,7 +69,7 @@ func usage() {
rollcall ip list [-subnet CIDR] | reserve IP [-name N] | release IP
rollcall admin-cert request NAME | list | revoke NAME|SERIAL admin client certificate (browser/CLI without the token)
rollcall dns [status] | sync [-dry] DNS records written directly (PowerDNS API / RFC 2136)
rollcall ipam [status] | sync [-dry] | prefixes | import external IPAM (NetBox): state, reconcile, delegated prefixes
rollcall ipam [status] | sync [-dry] | prefixes | import external IPAM (NetBox, Infoblox): state, reconcile, delegated prefixes
rollcall layout list | show NAME | add NAME FILE.json | rm NAME
rollcall net show ID | set ID [-layout L] [-rack R] [-static IFACE=IP] [-unstatic IP]
rollcall controllers | controller rm NAME | controller join-code [-name N] [-ttl 60] [-addr HOST:443] | dhcp CONTROLLER
+6 -2
View File
@@ -937,10 +937,14 @@ func (c *client) ipamCmd(sub string, args []string) error {
return err
}
if st.Provider == "" {
fmt.Println("built-in IPAM only (set ipam.provider in config.json for NetBox)")
fmt.Println("built-in IPAM only (set ipam.provider in config.json: netbox or infoblox)")
return nil
}
fmt.Printf("provider %s %s\nzone %s\nowner tag %q, custom field %q\n", st.Provider, st.URL, orDash(st.Zone), st.Tag, st.Field)
owner := fmt.Sprintf("tag %q, custom field %q", st.Tag, st.Field)
if st.Provider == "infoblox" {
owner = fmt.Sprintf("extensible attribute %q (delegated networks: %q)", st.Field, st.Tag)
}
fmt.Printf("provider %s %s\nzone %s\nowner %s\n", st.Provider, st.URL, orDash(st.Zone), owner)
if st.Last.At.IsZero() {
fmt.Println("last sync never")
return nil