// Package netbox resolves a PXE-booting machine (by MAC or hostname) into the // normalized model.Host that bootapi renders templates against. It talks to the // NetBox REST API (v4.x) behind the Resolver interface so the server can be // tested with an httptest fake. package netbox import ( "bytes" "context" "crypto/tls" "encoding/json" "errors" "fmt" "net" "net/http" "net/url" "strings" "time" "git.unkin.net/unkin/bootapi/internal/model" ) // ErrNotFound is returned when NetBox has no device matching the query. The // server maps it to an HTTP 404 (and, for iPXE, a safe fallback boot script). var ErrNotFound = errors.New("netbox: device not found") // Resolver turns a MAC or hostname into a fully-resolved Host. type Resolver interface { HostByMAC(ctx context.Context, mac string) (*model.Host, error) HostByName(ctx context.Context, name string) (*model.Host, error) } // Writer mutates NetBox. Today it only flips the pxe_enabled gate (the // end-of-kickstart callback). Kept separate from Resolver so read-only callers // need not depend on write scope. type Writer interface { SetPXEEnabled(ctx context.Context, deviceID int, enabled bool) error } // API is the full NetBox surface bootapi uses (reads + the pxe_enabled write). type API interface { Resolver Writer } // Client is the HTTP-backed Resolver. type Client struct { baseURL string token string http *http.Client } // Options configures a Client. type Options struct { BaseURL string Token string Timeout time.Duration Insecure bool // HTTPClient overrides the constructed client (used by tests). HTTPClient *http.Client } // New builds a NetBox Client. func New(o Options) *Client { hc := o.HTTPClient if hc == nil { tr := &http.Transport{} if o.Insecure { tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec // opt-in dev flag } timeout := o.Timeout if timeout == 0 { timeout = 5 * time.Second } hc = &http.Client{Timeout: timeout, Transport: tr} } return &Client{ baseURL: strings.TrimRight(o.BaseURL, "/"), token: o.Token, http: hc, } } // --- NetBox API JSON shapes (only the fields bootapi consumes) --- type nbList[T any] struct { Count int `json:"count"` Results []T `json:"results"` } type nbRef struct { ID int `json:"id"` Name string `json:"name"` Slug string `json:"slug"` } type nbVLAN struct { VID int `json:"vid"` Name string `json:"name"` } type nbMAC struct { MACAddress string `json:"mac_address"` } type nbDevice struct { ID int `json:"id"` Name string `json:"name"` Platform *nbRef `json:"platform"` Role *nbRef `json:"role"` Site *nbRef `json:"site"` PrimaryIP *nbIPRef `json:"primary_ip"` CustomFields map[string]any `json:"custom_fields"` } type nbIPRef struct { Address string `json:"address"` } type nbInterface struct { ID int `json:"id"` Name string `json:"name"` MACAddress string `json:"mac_address"` PrimaryMACAddress *nbMAC `json:"primary_mac_address"` UntaggedVLAN *nbVLAN `json:"untagged_vlan"` Device *nbRef `json:"device"` } func (i nbInterface) mac() string { if i.MACAddress != "" { return normalizeMAC(i.MACAddress) } if i.PrimaryMACAddress != nil { return normalizeMAC(i.PrimaryMACAddress.MACAddress) } return "" } type nbIPAddress struct { Address string `json:"address"` AssignedObjectID int `json:"assigned_object_id"` CustomFields map[string]any `json:"custom_fields"` } // --- Resolver implementation --- // HostByMAC finds the device owning an interface with the given MAC and // resolves it to a Host. func (c *Client) HostByMAC(ctx context.Context, mac string) (*model.Host, error) { mac = normalizeMAC(mac) if mac == "" { return nil, fmt.Errorf("netbox: empty MAC") } var list nbList[nbInterface] if err := c.get(ctx, "/api/dcim/interfaces/", url.Values{"mac_address": {mac}}, &list); err != nil { return nil, err } var dev *nbRef for _, i := range list.Results { if i.mac() == mac && i.Device != nil { dev = i.Device break } } if dev == nil { return nil, ErrNotFound } return c.resolveDevice(ctx, dev.ID) } // HostByName finds a device by its NetBox name and resolves it to a Host. func (c *Client) HostByName(ctx context.Context, name string) (*model.Host, error) { // Strip any domain suffix: NetBox device names are short hostnames. short := name if i := strings.IndexByte(short, '.'); i >= 0 { short = short[:i] } var list nbList[nbDevice] if err := c.get(ctx, "/api/dcim/devices/", url.Values{"name": {short}}, &list); err != nil { return nil, err } if len(list.Results) == 0 { return nil, ErrNotFound } return c.resolveDevice(ctx, list.Results[0].ID) } // resolveDevice fetches the device, its interfaces and IP addresses, and // assembles a Host. It performs three bounded API calls. func (c *Client) resolveDevice(ctx context.Context, id int) (*model.Host, error) { var dev nbDevice if err := c.get(ctx, fmt.Sprintf("/api/dcim/devices/%d/", id), nil, &dev); err != nil { return nil, err } devID := url.Values{"device_id": {fmt.Sprint(id)}} var ifaces nbList[nbInterface] if err := c.get(ctx, "/api/dcim/interfaces/", devID, &ifaces); err != nil { return nil, err } var ips nbList[nbIPAddress] if err := c.get(ctx, "/api/ipam/ip-addresses/", devID, &ips); err != nil { return nil, err } return buildHost(&dev, ifaces.Results, ips.Results), nil } // buildHost assembles the normalized Host from raw NetBox objects. It is pure // (no I/O) so it can be unit-tested directly against fixture structs. func buildHost(dev *nbDevice, ifaces []nbInterface, ips []nbIPAddress) *model.Host { cf := dev.CustomFields domain := cfString(cf, "domain") h := &model.Host{ DeviceID: dev.ID, Hostname: dev.Name, Domain: domain, Custom: cf, Nameservers: cfStringList(cf, "nameservers"), TemplateOverride: cfString(cf, "provision_template"), PXEEnabled: cfBool(cf, "pxe_enabled"), Arch: "x86_64", } if dev.Platform != nil { h.Platform = dev.Platform.Slug h.OSFamily, h.OSVersion = splitPlatform(dev.Platform.Slug) } if dev.Role != nil { h.Role = dev.Role.Slug } // Index IP addresses by the interface they are assigned to. ipByIface := map[int]nbIPAddress{} for _, ip := range ips { if _, seen := ipByIface[ip.AssignedObjectID]; !seen { ipByIface[ip.AssignedObjectID] = ip } } primaryAddr := "" if dev.PrimaryIP != nil { primaryAddr = dev.PrimaryIP.Address h.PrimaryIP = addrOnly(primaryAddr) } deviceGateway := cfString(cf, "gateway") for _, in := range ifaces { iface := model.Interface{ Name: in.Name, MAC: in.mac(), } if in.UntaggedVLAN != nil { iface.VLAN = in.UntaggedVLAN.VID } if ip, ok := ipByIface[in.ID]; ok { iface.IP = addrOnly(ip.Address) iface.PrefixLen = prefixLen(ip.Address) iface.Netmask = netmaskFor(iface.PrefixLen) if g := cfString(ip.CustomFields, "gateway"); g != "" { iface.Gateway = g } if iface.IP != "" && iface.IP == h.PrimaryIP { iface.Primary = true } } if iface.Gateway == "" { iface.Gateway = deviceGateway } h.Interfaces = append(h.Interfaces, iface) } sortPrimaryFirst(h.Interfaces) if h.Domain != "" { h.FQDN = h.Hostname + "." + h.Domain } else { h.FQDN = h.Hostname } return h } // sortPrimaryFirst moves the primary interface to the front, preserving the // relative order of the rest. func sortPrimaryFirst(ifaces []model.Interface) { for i := range ifaces { if ifaces[i].Primary && i != 0 { p := ifaces[i] copy(ifaces[1:i+1], ifaces[0:i]) ifaces[0] = p return } } } // SetPXEEnabled PATCHes the device's pxe_enabled custom field. This is the only // write bootapi performs; the NetBox token therefore needs write scope on the // device custom field (see docs/security.md). func (c *Client) SetPXEEnabled(ctx context.Context, deviceID int, enabled bool) error { body := map[string]any{"custom_fields": map[string]any{"pxe_enabled": enabled}} b, err := json.Marshal(body) if err != nil { return err } path := fmt.Sprintf("/api/dcim/devices/%d/", deviceID) req, err := http.NewRequestWithContext(ctx, http.MethodPatch, c.baseURL+path, bytes.NewReader(b)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") if c.token != "" { req.Header.Set("Authorization", "Token "+c.token) } resp, err := c.http.Do(req) if err != nil { return fmt.Errorf("netbox patch device %d: %w", deviceID, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode == http.StatusNotFound { return ErrNotFound } if resp.StatusCode != http.StatusOK { return fmt.Errorf("netbox patch device %d: HTTP %d", deviceID, resp.StatusCode) } return nil } // get performs a GET against the NetBox API and decodes the JSON body into out. func (c *Client) get(ctx context.Context, path string, q url.Values, out any) error { u := c.baseURL + path if len(q) > 0 { u += "?" + q.Encode() } req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return err } req.Header.Set("Accept", "application/json") if c.token != "" { req.Header.Set("Authorization", "Token "+c.token) } resp, err := c.http.Do(req) if err != nil { return fmt.Errorf("netbox request %s: %w", path, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode == http.StatusNotFound { return ErrNotFound } if resp.StatusCode != http.StatusOK { return fmt.Errorf("netbox %s: HTTP %d", path, resp.StatusCode) } if err := json.NewDecoder(resp.Body).Decode(out); err != nil { return fmt.Errorf("netbox decode %s: %w", path, err) } return nil } // --- helpers --- func normalizeMAC(s string) string { s = strings.TrimSpace(s) if s == "" { return "" } // Drop common separators then re-insert colons every 2 hex chars. var hex strings.Builder for _, r := range strings.ToLower(s) { if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') { hex.WriteRune(r) } } h := hex.String() if len(h) != 12 { // Not a canonical 48-bit MAC; return lower-cased trimmed input. return strings.ToLower(s) } var b strings.Builder for i := 0; i < 12; i += 2 { if i > 0 { b.WriteByte(':') } b.WriteString(h[i : i+2]) } return b.String() } func addrOnly(cidr string) string { if i := strings.IndexByte(cidr, '/'); i >= 0 { return cidr[:i] } return cidr } func prefixLen(cidr string) int { _, ipnet, err := net.ParseCIDR(cidr) if err != nil { return 0 } ones, _ := ipnet.Mask.Size() return ones } func netmaskFor(prefix int) string { if prefix <= 0 || prefix > 32 { return "" } mask := net.CIDRMask(prefix, 32) return fmt.Sprintf("%d.%d.%d.%d", mask[0], mask[1], mask[2], mask[3]) } // splitPlatform derives (family, majorVersion) from a NetBox platform slug such // as "almalinux9" -> ("almalinux","9") or "fedora42" -> ("fedora","42"). func splitPlatform(slug string) (family, version string) { slug = strings.ToLower(slug) i := strings.IndexFunc(slug, func(r rune) bool { return r >= '0' && r <= '9' }) if i < 0 { return slug, "" } family = strings.Trim(slug[:i], "-_") version = slug[i:] if d := strings.IndexByte(version, '.'); d >= 0 { version = version[:d] } return family, version } func cfString(cf map[string]any, key string) string { if cf == nil { return "" } switch v := cf[key].(type) { case string: return v case map[string]any: // NetBox object custom fields serialize as {value,label} if s, ok := v["value"].(string); ok { return s } } return "" } // cfBool reads a boolean custom field. Returns nil when the field is absent or // null so callers can distinguish "unset" from "false". func cfBool(cf map[string]any, key string) *bool { if cf == nil { return nil } switch v := cf[key].(type) { case bool: return &v case string: // tolerate "true"/"false" string encodings switch strings.ToLower(v) { case "true", "1", "yes": b := true return &b case "false", "0", "no": b := false return &b } } return nil } func cfStringList(cf map[string]any, key string) []string { if cf == nil { return nil } switch v := cf[key].(type) { case string: return splitComma(v) case []any: var out []string for _, e := range v { if s, ok := e.(string); ok && s != "" { out = append(out, s) } } return out } return nil } func splitComma(v string) []string { var out []string for _, p := range strings.Split(v, ",") { if p = strings.TrimSpace(p); p != "" { out = append(out, p) } } return out }