diff --git a/CHANGELOG.md b/CHANGELOG.md index 57f3311..35eba16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 5b0a2b7..1d52de5 100644 --- a/README.md +++ b/README.md @@ -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-
` + 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) diff --git a/cmd/rollcall/infoblox.go b/cmd/rollcall/infoblox.go new file mode 100644 index 0000000..8810ffa --- /dev/null +++ b/cmd/rollcall/infoblox.go @@ -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-
+// 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 +} diff --git a/cmd/rollcall/infoblox_test.go b/cmd/rollcall/infoblox_test.go new file mode 100644 index 0000000..0e97ea8 --- /dev/null +++ b/cmd/rollcall/infoblox_test.go @@ -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) } diff --git a/cmd/rollcall/ipam.go b/cmd/rollcall/ipam.go index b64aadf..81eb427 100644 --- a/cmd/rollcall/ipam.go +++ b/cmd/rollcall/ipam.go @@ -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 = . 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 { diff --git a/cmd/rollcall/main.go b/cmd/rollcall/main.go index 7be4e1d..d5effe6 100644 --- a/cmd/rollcall/main.go +++ b/cmd/rollcall/main.go @@ -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 diff --git a/cmd/rollcall/netapi.go b/cmd/rollcall/netapi.go index 4d0f9c1..a045344 100644 --- a/cmd/rollcall/netapi.go +++ b/cmd/rollcall/netapi.go @@ -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