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
@@ -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
|
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
|
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.
|
`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.
|
- 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
|
- 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
|
`host_cmds` (Kea 3.0+) and OpenWrt (rpcd). Rollcall's own leases are reconciled; foreign ones are
|
||||||
|
|||||||
@@ -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
|
dual-stack on two segments, AAAA lookups through dnsmasq, the admin CLI over
|
||||||
`https://[fdc3:...]`.
|
`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
|
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
|
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.devicetype` and `dcim.devicerole`, and `view` on `dcim.location` and
|
||||||
`dcim.rack`, on top of the IPAM permissions.
|
`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,
|
The provider is an interface (reserve, owned records, update, delete,
|
||||||
delegated prefixes); the NetBox driver is the first. An Infoblox WAPI driver
|
delegated prefixes) with the NetBox and Infoblox drivers.
|
||||||
maps them to `record:host`/`fixedaddress` with extensible attributes.
|
|
||||||
|
|
||||||
## DNS provider (PowerDNS, RFC 2136)
|
## DNS provider (PowerDNS, RFC 2136)
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
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
|
URL string `json:"url"` // https://netbox.example
|
||||||
TokenFile string `json:"token_file"` // API token (NetBox v2 "nbt_..." or v1)
|
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)
|
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
|
DNSZone string `json:"dns_zone"` // dns_name = <host>.<zone> unless the subnet has a domain
|
||||||
Tag string `json:"tag"` // ownership tag, default "rollcall"
|
Tag string `json:"tag"` // ownership tag, default "rollcall"
|
||||||
@@ -120,8 +124,10 @@ func newIPAMProvider(c IPAMConfig) (ipamProvider, error) {
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
case "netbox":
|
case "netbox":
|
||||||
return newNetBox(c)
|
return newNetBox(c)
|
||||||
|
case "infoblox":
|
||||||
|
return newInfoblox(c)
|
||||||
default:
|
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.ipam = p
|
||||||
a.ipamSt = &ipamState{kick: make(chan struct{}, 1)}
|
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
|
every := 5 * time.Minute
|
||||||
if d, err := time.ParseDuration(a.cfg.IPAM.SyncInterval); err == nil && d >= 10*time.Second {
|
if d, err := time.ParseDuration(a.cfg.IPAM.SyncInterval); err == nil && d >= 10*time.Second {
|
||||||
every = d
|
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))
|
want := a.extFor(x, subnetByRef(subnets, x.Subnet))
|
||||||
if o, ok := have[x.IP]; ok {
|
if o, ok := have[x.IP]; ok {
|
||||||
delete(have, x.IP)
|
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
|
want.ID = o.ID
|
||||||
rep.Updated = append(rep.Updated, x.IP)
|
rep.Updated = append(rep.Updated, x.IP)
|
||||||
if !dry {
|
if !dry {
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ func usage() {
|
|||||||
rollcall ip list [-subnet CIDR] | reserve IP [-name N] | release IP
|
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 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 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 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 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
|
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
|
return err
|
||||||
}
|
}
|
||||||
if st.Provider == "" {
|
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
|
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() {
|
if st.Last.At.IsZero() {
|
||||||
fmt.Println("last sync never")
|
fmt.Println("last sync never")
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
Reference in new issue
Block a user