From 32497734221f529846415ad34c1979fed8e98c5c Mon Sep 17 00:00:00 2001 From: Mike Crute Date: Sat, 21 May 2022 19:31:43 -0700 Subject: echo: add netbox-based IP filter --- echo/middleware/ip_filter.go | 141 ++++++++++++++++++++++++++++++------ echo/netbox/client.go | 169 +++++++++++++++++++++++++++++++++++++++++++ echo/netbox/model.go | 74 +++++++++++++++++++ 3 files changed, 361 insertions(+), 23 deletions(-) create mode 100644 echo/netbox/client.go create mode 100644 echo/netbox/model.go diff --git a/echo/middleware/ip_filter.go b/echo/middleware/ip_filter.go index 007791e..2d79925 100644 --- a/echo/middleware/ip_filter.go +++ b/echo/middleware/ip_filter.go @@ -1,39 +1,134 @@ package middleware import ( + "context" + "fmt" "net" + "sync" + "time" + "code.crute.us/mcrute/golib/echo/netbox" "github.com/labstack/echo/v4" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" ) -func NewIPFilter(allowedRanges []*net.IPNet) echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - if allowedRanges == nil { - c.Logger().Error("No allowed IPs configured for filter") - return echo.ErrNotFound - } +var netboxFilterFailures = promauto.NewCounter(prometheus.CounterOpts{ + Name: "netbox_ip_filter_refresh_failures", + Help: "Total number of failures refreshing netbox sourced IP ranges", +}) - ip := net.ParseIP(c.RealIP()) - if ip == nil { - c.Logger().Error("Unable to parse IP in IPFilter") - return echo.ErrNotFound - } +type ipFilter struct { + sync.RWMutex + allowLocalhost bool + allowedRanges []*net.IPNet +} - found := false - for _, ipnet := range allowedRanges { - if ipnet.Contains(ip) { - found = true - break - } - } +func (f *ipFilter) UpdateRanges(r []*net.IPNet) { + f.Lock() + defer f.Unlock() + f.allowedRanges = r +} + +func (f *ipFilter) Middleware(next echo.HandlerFunc) echo.HandlerFunc { + _, v4Localhost, _ := net.ParseCIDR("127.0.0.0/8") + _, v6Localhost, _ := net.ParseCIDR("::1/128") + + return func(c echo.Context) error { + f.RLock() + defer f.RUnlock() + + if f.allowedRanges == nil { + c.Logger().Error("No allowed IPs configured for filter") + return echo.ErrNotFound + } - if !found { - c.Logger().Errorf("IP %s not in range for filter", c.RealIP()) - return echo.ErrNotFound + ip := net.ParseIP(c.RealIP()) + if ip == nil { + c.Logger().Error("Unable to parse IP in IPFilter") + return echo.ErrNotFound + } + + found := false + for _, ipnet := range f.allowedRanges { + if ipnet.Contains(ip) { + found = true + break } + } + + if f.allowLocalhost && (v4Localhost.Contains(ip) || v6Localhost.Contains(ip)) { + found = true + } + + if !found { + c.Logger().Errorf("IP %s not in range for filter", c.RealIP()) + return echo.ErrNotFound + } + + return next(c) + } +} - return next(c) +func NewIPFilter(allowedRanges []*net.IPNet) echo.MiddlewareFunc { + return (&ipFilter{allowedRanges: allowedRanges}).Middleware +} + +type NetboxIPFilter struct { + NetboxClient netbox.NetboxClient + Tag string + IncludeLocalhost bool + Logger echo.Logger + f *ipFilter + hasInit bool +} + +func (f *NetboxIPFilter) Init() error { + nets, err := f.NetboxClient.GetPrefixesWithTag(f.Tag) + if err != nil { + return err + } + f.Logger.Debugf("Got prefixes: %s", nets) + + f.f = &ipFilter{ + allowedRanges: nets, + allowLocalhost: f.IncludeLocalhost, + } + f.hasInit = true + + return nil +} + +func (f *NetboxIPFilter) Middleware(next echo.HandlerFunc) echo.HandlerFunc { + return f.f.Middleware(next) +} + +func (f *NetboxIPFilter) RunRefresh(c context.Context, wg *sync.WaitGroup) error { + wg.Add(1) + defer wg.Done() + + if !f.hasInit { + return fmt.Errorf("NetboxIPFilter: has not been initialized before RunRefresh called") + } + + f.Logger.Info("Starting netbox IP address filter refresh loop") + + t := time.NewTicker(time.Hour) + defer t.Stop() + + for { + select { + case <-t.C: + if nets, err := f.NetboxClient.GetPrefixesWithTag(f.Tag); err != nil { + f.Logger.Errorf("Error refreshing netbox prefixes for IP filter: %w", err) + netboxFilterFailures.Inc() + } else { + f.Logger.Debugf("Got prefixes: %s", nets) + f.f.UpdateRanges(nets) + } + case <-c.Done(): + f.Logger.Info("Shutting down netbox IP address filter refresh loop") + return nil } } } diff --git a/echo/netbox/client.go b/echo/netbox/client.go new file mode 100644 index 0000000..aabadc8 --- /dev/null +++ b/echo/netbox/client.go @@ -0,0 +1,169 @@ +package netbox + +import ( + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strconv" + + "code.crute.us/mcrute/golib/vault" +) + +type NetboxClient interface { + GetSitePrefixesWithTag(site string, tag string) ([]*net.IPNet, error) + GetPrefixesWithTag(tag string) ([]*net.IPNet, error) +} + +func NewNetboxClientDefault() NetboxClient { + return &netboxClient{ + endpoint: "https://netbox.crute.me", + vaultMaterial: "infra/netbox-readonly", + } +} + +func NewNetboxClient(endpoint string, vaultMaterial string) NetboxClient { + return &netboxClient{ + endpoint: endpoint, + vaultMaterial: vaultMaterial, + } +} + +type netboxClient struct { + endpoint string + vaultMaterial string +} + +func (c *netboxClient) makeRequestRaw(method, u string, ib io.Reader, o interface{}) error { + apiKey, err := vault.GetVaultApiKey(c.vaultMaterial) + if err != nil { + return err + } + + req, err := http.NewRequest(method, u, ib) + if err != nil { + return err + } + req.Header.Add("Authorization", fmt.Sprintf("Token %s", apiKey)) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + if err = json.NewDecoder(res.Body).Decode(o); err != nil { + return err + } + + return nil +} + +func (c *netboxClient) makeRequest(method, path string, q url.Values, ib io.Reader, o interface{}) error { + u, err := url.Parse(c.endpoint) + if err != nil { + return err + } + u.Path = path + u.RawQuery = q.Encode() + + return c.makeRequestRaw(method, u.String(), ib, o) +} + +func (c *netboxClient) GetSitePrefixesWithTag(site string, tag string) ([]*net.IPNet, error) { + s, err := c.resolveSiteNameToId(site) + if err != nil { + return nil, err + } + + out := []*net.IPNet{} + + q := url.Values{} + q.Add("site_id", strconv.Itoa(s)) + q.Add("tag", tag) + + page := &PrefixList{} + if err := c.makeRequest(http.MethodGet, "/api/ipam/prefixes/", q, nil, page); err != nil { + return nil, err + } + + for _, r := range page.Results { + _, ipnet, err := net.ParseCIDR(r.Prefix) + if err != nil { + return nil, err + } + out = append(out, ipnet) + } + + for page.Next != "" { + page = &PrefixList{} + if err := c.makeRequestRaw(http.MethodGet, page.Next, nil, page); err != nil { + return nil, err + } + + for _, r := range page.Results { + _, ipnet, err := net.ParseCIDR(r.Prefix) + if err != nil { + return nil, err + } + out = append(out, ipnet) + } + } + + return out, nil +} + +func (c *netboxClient) GetPrefixesWithTag(tag string) ([]*net.IPNet, error) { + out := []*net.IPNet{} + + q := url.Values{} + q.Add("tag", tag) + + page := &PrefixList{} + if err := c.makeRequest(http.MethodGet, "/api/ipam/prefixes/", q, nil, page); err != nil { + return nil, err + } + + for _, r := range page.Results { + _, ipnet, err := net.ParseCIDR(r.Prefix) + if err != nil { + return nil, err + } + out = append(out, ipnet) + } + + for page.Next != "" { + page = &PrefixList{} + if err := c.makeRequestRaw(http.MethodGet, page.Next, nil, page); err != nil { + return nil, err + } + + for _, r := range page.Results { + _, ipnet, err := net.ParseCIDR(r.Prefix) + if err != nil { + return nil, err + } + out = append(out, ipnet) + } + } + + return out, nil +} + +func (c *netboxClient) resolveSiteNameToId(s string) (int, error) { + q := url.Values{} + q.Add("name", s) + + out := &SiteList{} + if err := c.makeRequest(http.MethodGet, "/api/dcim/sites/", q, nil, out); err != nil { + return 0, err + } + + if len(out.Results) == 0 { + return 0, fmt.Errorf("resolveSiteNameToId: no results returned from netbox") + } + + return out.Results[0].ID, nil +} diff --git a/echo/netbox/model.go b/echo/netbox/model.go new file mode 100644 index 0000000..78f2ca6 --- /dev/null +++ b/echo/netbox/model.go @@ -0,0 +1,74 @@ +package netbox + +type LabeledInt struct { + Value int `json:"value"` + Label string `json:"label"` +} + +type LabeledString struct { + Value string `json:"value"` + Label string `json:"label"` +} + +type Role struct { + ID int `json:"ID"` + Url string `json:"url"` + Display string `json:"display"` + Name string `json:"name"` + Slug string `json:"slub"` +} + +type Tag struct { + ID int `json:"id"` + Url string `json:"url"` + Display string `json:"display"` + Name string `json:"name"` + Slug string `json:"slug"` + Color string `json:"color"` +} + +type SiteList struct { + Count int `json:"count"` + Next string `json:"next"` + Previous string `json:"previous"` + Results []*Site `json:"results"` +} + +type Site struct { + ID int `json:"id"` + Url string `json:"url"` + Display string `json:"display"` + Name string `json:"name"` + Slug string `json:"slug"` + Facility string `json:"facility"` + Description string `json:"description"` + Timezone string `json:"time_zone"` + ASN int `json:"asn"` + Status LabeledString `json:"status"` +} + +type PrefixList struct { + Count int `json:"count"` + Next string `json:"next"` + Previous string `json:"previous"` + Results []*Prefix `json:"results"` +} + +type Prefix struct { + ID int `json:"ID"` + Url string `json:"url"` + Display string `json:"display"` + Prefix string `json:"prefix"` + IsPool bool `json:"is_pool"` + Description string `json:"description"` + Created string `json:"created"` + LastUpdated string `json:"last_updated"` + Children int `json:"children"` + Depth int `json:"_depth"` + Family LabeledInt `json:"family"` + Status LabeledString `json:"status"` + Site *Site `json:"site"` + Role *Role `json:"role"` + Tags []*Tag `json:"tags"` + CustomFields map[string]interface{} `json:"custom_fields"` +} -- cgit v1.2.3