Infoblox WAPI driver for the IPAM authority
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:
1 parent
eb2b7baa9f
commit
008fa3f66f
7 files changed
+731
-9
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user