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

+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