diff --git a/internal/config/config.go b/internal/config/config.go index e499ca5..3825936 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -20,10 +20,17 @@ type DNSMasqConfig struct { LeasesPath string } +type NetboxConfig struct { + Host string `json:"host"` + APIKey string `json:"api_key"` + VRFName string `json:"vrf"` +} + type Config struct { HealthchecksConfig `json:"healthchecks"` DNSMasqConfigFile string `json:"dnsmasq_config"` DNSMasqConfig + NetboxConfig `json:"netbox"` } func (c *Config) parseDNSMasqConf() error { diff --git a/internal/netbox/netbox.go b/internal/netbox/netbox.go new file mode 100644 index 0000000..7db46c5 --- /dev/null +++ b/internal/netbox/netbox.go @@ -0,0 +1,213 @@ +package netbox + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/netip" + "net/url" + + "git.faercol.me/faercol/dnsmasq-netbox-connector/internal/config" +) + +type NetboxClient struct { + conf *config.NetboxConfig +} + +type Device struct { + ID int `json:"id"` + Name string `json:"name"` +} + +func (d Device) String() string { + return fmt.Sprintf("Device %d (%s)", d.ID, d.Name) +} + +type Interface struct { + ID int `json:"id"` + Name string `json:"name"` + Device *Device `json:"device"` + MacAddress string `json:"mac_address"` +} + +func (i Interface) String() string { + baseStr := fmt.Sprintf("Interface %d (%s - %s)", i.ID, i.Name, i.MacAddress) + if i.Device == nil { + return baseStr + " not associated to a device" + } + return baseStr + " associated device: " + i.Device.String() +} + +type IP struct { + ID int `json:"id"` + Address netip.Prefix + Interface *Interface `json:"assigned_object"` +} + +func (i IP) String() string { + baseStr := fmt.Sprintf("IP %d (%s)", i.ID, i.Address.String()) + if i.Interface == nil { + return baseStr + " unused" + } + return baseStr + " associated interface: " + i.Interface.String() +} + +type ipsResult struct { + Count int `json:"count"` + Results []IP `json:"results"` +} + +type interfacesResult struct { + Count int `json:"count"` + Results []*Interface `json:"results"` +} + +func (c *NetboxClient) GetUsedDHCPAddresses(ctx context.Context) (used []*IP, free []*IP, err error) { + clt := http.DefaultClient + + reqURL, err := url.JoinPath(c.conf.Host, "api/ipam/ip-addresses") + if err != nil { + return nil, nil, fmt.Errorf("failed to parse URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, nil, fmt.Errorf("failed to prepare request: %w", err) + } + q := req.URL.Query() + q.Add("vrf_name", c.conf.VRFName) + q.Add("status", "dhcp") + // q.Add("assigned_object_id__empty", "False") + req.URL.RawQuery = q.Encode() + req.Header.Set("Authorization", "Token "+c.conf.APIKey) + + resp, err := clt.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("failed to run query: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, nil, fmt.Errorf("unexpected returncode: %d", resp.StatusCode) + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + + var res ipsResult + if err := json.Unmarshal(respBody, &res); err != nil { + return nil, nil, fmt.Errorf("failed to parse response: %w", err) + } + + for _, r := range res.Results { + if r.Interface == nil { + free = append(free, &r) + } else { + used = append(used, &r) + } + } + + return +} + +func (c *NetboxClient) GetAllInterfaces(ctx context.Context) ([]*Interface, error) { + clt := http.DefaultClient + + reqURL, err := url.JoinPath(c.conf.Host, "api/dcim/interfaces") + if err != nil { + return nil, fmt.Errorf("failed to parse URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to prepare request: %w", err) + } + q := req.URL.Query() + req.URL.RawQuery = q.Encode() + req.Header.Set("Authorization", "Token "+c.conf.APIKey) + + resp, err := clt.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to run query: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected returncode: %d", resp.StatusCode) + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + var res interfacesResult + if err := json.Unmarshal(respBody, &res); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return res.Results, nil +} + +func (c *NetboxClient) UpdateIPs(ctx context.Context, unassignedIPs []*IP, updatedIPs []*IP) error { + clt := http.DefaultClient + + reqURL, err := url.JoinPath(c.conf.Host, "api/ipam/ip-addresses/") + if err != nil { + return fmt.Errorf("failed to parse URL: %w", err) + } + + queryData := []map[string]interface{}{} + for _, i := range unassignedIPs { + ifaceData := map[string]interface{}{ + "id": i.ID, + "assigned_object_type": nil, + "assigned_object_id": nil, + } + queryData = append(queryData, ifaceData) + } + for _, i := range updatedIPs { + ifaceData := map[string]interface{}{ + "id": i.ID, + "assigned_object_type": "dcim.interface", + "assigned_object_id": i.Interface.ID, + } + queryData = append(queryData, ifaceData) + } + queryBody, err := json.Marshal(queryData) + if err != nil { + return fmt.Errorf("failed to serialize body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, reqURL, bytes.NewBuffer(queryBody)) + if err != nil { + return fmt.Errorf("failed to prepare request: %w", err) + } + q := req.URL.Query() + req.URL.RawQuery = q.Encode() + req.Header.Set("Authorization", "Token "+c.conf.APIKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := clt.Do(req) + if err != nil { + return fmt.Errorf("failed to run query: %w", err) + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected returncode: %d (%s)", resp.StatusCode, string(respBody)) + } + + return nil +} + +func New(conf config.Config) *NetboxClient { + return &NetboxClient{ + conf: &conf.NetboxConfig, + } +} diff --git a/main.go b/main.go index cd00874..d5a396c 100644 --- a/main.go +++ b/main.go @@ -4,10 +4,12 @@ import ( "context" "fmt" "os" + "strings" "git.faercol.me/faercol/dnsmasq-netbox-connector/internal/config" "git.faercol.me/faercol/dnsmasq-netbox-connector/internal/dnsmasq" "git.faercol.me/faercol/dnsmasq-netbox-connector/internal/healthchecks" + "git.faercol.me/faercol/dnsmasq-netbox-connector/internal/netbox" gohealthchecks "git.faercol.me/faercol/go-healthchecks" ) @@ -44,7 +46,90 @@ func main() { fmt.Printf("Got lease %s\n", l) } + netboxClt := netbox.New(*conf) + assignedIP, freeIP, err := netboxClt.GetUsedDHCPAddresses(context.Background()) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get list of assigned IPs: %s\n", err) + if err := healthchecks.Failure(context.Background(), conf.HealthchecksConfig, healtchecksClt, check, "failed to get assigned IPs"); err != nil { + fmt.Fprintf(os.Stderr, "Failed to notify failure: %s\n", err) + } + os.Exit(1) + } + + allInterfaces, err := netboxClt.GetAllInterfaces(context.Background()) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to get list of interfaces: %s\n", err) + if err := healthchecks.Failure(context.Background(), conf.HealthchecksConfig, healtchecksClt, check, "failed to get interfaces"); err != nil { + fmt.Fprintf(os.Stderr, "Failed to notify failure: %s\n", err) + } + os.Exit(1) + } + + toDelete, toAdd := reconcile(leases, assignedIP, freeIP, allInterfaces) + for _, a := range toDelete { + fmt.Printf("Following IP should be unassigned: %s\n", a.String()) + } + for _, a := range toAdd { + fmt.Printf("Following IP should be assigned: %s\n", a.String()) + } + + if err := netboxClt.UpdateIPs(context.Background(), toDelete, toAdd); err != nil { + fmt.Fprintf(os.Stderr, "Failed to update IP assignment in netbox: %s\n", err) + if err := healthchecks.Failure(context.Background(), conf.HealthchecksConfig, healtchecksClt, check, "failed to update assignment"); err != nil { + fmt.Fprintf(os.Stderr, "Failed to notify failure: %s\n", err) + } + os.Exit(1) + } + if err := healthchecks.Success(context.Background(), conf.HealthchecksConfig, healtchecksClt, check); err != nil { fmt.Fprintf(os.Stderr, "Failed to notify process success: %s\n", err) } } + +// this method would probably require a bit of refacto, it's quite ugly +func reconcile(leases []*dnsmasq.Lease, assigned []*netbox.IP, free []*netbox.IP, interfaces []*netbox.Interface) (toDelete []*netbox.IP, toAdd []*netbox.IP) { + dhcpMapping := map[string]string{} + for _, l := range leases { + dhcpMapping[l.IP.String()] = l.Mac + } + + assignedMapping := map[string]string{} + freeMapping := map[string]*netbox.IP{} + + interfaceMapping := map[string]*netbox.Interface{} + for _, i := range interfaces { + if i.MacAddress == "" { + continue + } + interfaceMapping[strings.ToLower(i.MacAddress)] = i + } + + for _, a := range assigned { + assignedMapping[a.Address.Addr().String()] = "" + _, ok := dhcpMapping[a.Address.Addr().String()] + if !ok { + toDelete = append(toDelete, a) + } + } + for _, a := range free { + freeMapping[a.Address.Addr().String()] = a + } + + for _, l := range leases { + _, ok := assignedMapping[l.IP.String()] + if !ok { + iface, ok := interfaceMapping[strings.ToLower(l.Mac)] + if !ok { + continue + } + ip, ok := freeMapping[l.IP.String()] + if !ok { + continue + } + ip.Interface = iface + toAdd = append(toAdd, ip) + } + } + + return +}