diff options
author | Mike Crute <mike@crute.us> | 2022-05-21 19:31:43 -0700 |
---|---|---|
committer | Mike Crute <mike@crute.us> | 2022-05-21 19:33:34 -0700 |
commit | 32497734221f529846415ad34c1979fed8e98c5c (patch) | |
tree | f9e985663ca73ea3097007166340d832f5e40426 /echo | |
parent | 43ea770a0f42d650864ec313a26cf0f1a3824f1c (diff) | |
download | golib-32497734221f529846415ad34c1979fed8e98c5c.tar.bz2 golib-32497734221f529846415ad34c1979fed8e98c5c.tar.xz golib-32497734221f529846415ad34c1979fed8e98c5c.zip |
echo: add netbox-based IP filter
Diffstat (limited to 'echo')
-rw-r--r-- | echo/middleware/ip_filter.go | 141 | ||||
-rw-r--r-- | echo/netbox/client.go | 169 | ||||
-rw-r--r-- | echo/netbox/model.go | 74 |
3 files changed, 361 insertions, 23 deletions
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 @@ | |||
1 | package middleware | 1 | package middleware |
2 | 2 | ||
3 | import ( | 3 | import ( |
4 | "context" | ||
5 | "fmt" | ||
4 | "net" | 6 | "net" |
7 | "sync" | ||
8 | "time" | ||
5 | 9 | ||
10 | "code.crute.us/mcrute/golib/echo/netbox" | ||
6 | "github.com/labstack/echo/v4" | 11 | "github.com/labstack/echo/v4" |
12 | "github.com/prometheus/client_golang/prometheus" | ||
13 | "github.com/prometheus/client_golang/prometheus/promauto" | ||
7 | ) | 14 | ) |
8 | 15 | ||
9 | func NewIPFilter(allowedRanges []*net.IPNet) echo.MiddlewareFunc { | 16 | var netboxFilterFailures = promauto.NewCounter(prometheus.CounterOpts{ |
10 | return func(next echo.HandlerFunc) echo.HandlerFunc { | 17 | Name: "netbox_ip_filter_refresh_failures", |
11 | return func(c echo.Context) error { | 18 | Help: "Total number of failures refreshing netbox sourced IP ranges", |
12 | if allowedRanges == nil { | 19 | }) |
13 | c.Logger().Error("No allowed IPs configured for filter") | ||
14 | return echo.ErrNotFound | ||
15 | } | ||
16 | 20 | ||
17 | ip := net.ParseIP(c.RealIP()) | 21 | type ipFilter struct { |
18 | if ip == nil { | 22 | sync.RWMutex |
19 | c.Logger().Error("Unable to parse IP in IPFilter") | 23 | allowLocalhost bool |
20 | return echo.ErrNotFound | 24 | allowedRanges []*net.IPNet |
21 | } | 25 | } |
22 | 26 | ||
23 | found := false | 27 | func (f *ipFilter) UpdateRanges(r []*net.IPNet) { |
24 | for _, ipnet := range allowedRanges { | 28 | f.Lock() |
25 | if ipnet.Contains(ip) { | 29 | defer f.Unlock() |
26 | found = true | 30 | f.allowedRanges = r |
27 | break | 31 | } |
28 | } | 32 | |
29 | } | 33 | func (f *ipFilter) Middleware(next echo.HandlerFunc) echo.HandlerFunc { |
34 | _, v4Localhost, _ := net.ParseCIDR("127.0.0.0/8") | ||
35 | _, v6Localhost, _ := net.ParseCIDR("::1/128") | ||
36 | |||
37 | return func(c echo.Context) error { | ||
38 | f.RLock() | ||
39 | defer f.RUnlock() | ||
40 | |||
41 | if f.allowedRanges == nil { | ||
42 | c.Logger().Error("No allowed IPs configured for filter") | ||
43 | return echo.ErrNotFound | ||
44 | } | ||
30 | 45 | ||
31 | if !found { | 46 | ip := net.ParseIP(c.RealIP()) |
32 | c.Logger().Errorf("IP %s not in range for filter", c.RealIP()) | 47 | if ip == nil { |
33 | return echo.ErrNotFound | 48 | c.Logger().Error("Unable to parse IP in IPFilter") |
49 | return echo.ErrNotFound | ||
50 | } | ||
51 | |||
52 | found := false | ||
53 | for _, ipnet := range f.allowedRanges { | ||
54 | if ipnet.Contains(ip) { | ||
55 | found = true | ||
56 | break | ||
34 | } | 57 | } |
58 | } | ||
59 | |||
60 | if f.allowLocalhost && (v4Localhost.Contains(ip) || v6Localhost.Contains(ip)) { | ||
61 | found = true | ||
62 | } | ||
63 | |||
64 | if !found { | ||
65 | c.Logger().Errorf("IP %s not in range for filter", c.RealIP()) | ||
66 | return echo.ErrNotFound | ||
67 | } | ||
68 | |||
69 | return next(c) | ||
70 | } | ||
71 | } | ||
35 | 72 | ||
36 | return next(c) | 73 | func NewIPFilter(allowedRanges []*net.IPNet) echo.MiddlewareFunc { |
74 | return (&ipFilter{allowedRanges: allowedRanges}).Middleware | ||
75 | } | ||
76 | |||
77 | type NetboxIPFilter struct { | ||
78 | NetboxClient netbox.NetboxClient | ||
79 | Tag string | ||
80 | IncludeLocalhost bool | ||
81 | Logger echo.Logger | ||
82 | f *ipFilter | ||
83 | hasInit bool | ||
84 | } | ||
85 | |||
86 | func (f *NetboxIPFilter) Init() error { | ||
87 | nets, err := f.NetboxClient.GetPrefixesWithTag(f.Tag) | ||
88 | if err != nil { | ||
89 | return err | ||
90 | } | ||
91 | f.Logger.Debugf("Got prefixes: %s", nets) | ||
92 | |||
93 | f.f = &ipFilter{ | ||
94 | allowedRanges: nets, | ||
95 | allowLocalhost: f.IncludeLocalhost, | ||
96 | } | ||
97 | f.hasInit = true | ||
98 | |||
99 | return nil | ||
100 | } | ||
101 | |||
102 | func (f *NetboxIPFilter) Middleware(next echo.HandlerFunc) echo.HandlerFunc { | ||
103 | return f.f.Middleware(next) | ||
104 | } | ||
105 | |||
106 | func (f *NetboxIPFilter) RunRefresh(c context.Context, wg *sync.WaitGroup) error { | ||
107 | wg.Add(1) | ||
108 | defer wg.Done() | ||
109 | |||
110 | if !f.hasInit { | ||
111 | return fmt.Errorf("NetboxIPFilter: has not been initialized before RunRefresh called") | ||
112 | } | ||
113 | |||
114 | f.Logger.Info("Starting netbox IP address filter refresh loop") | ||
115 | |||
116 | t := time.NewTicker(time.Hour) | ||
117 | defer t.Stop() | ||
118 | |||
119 | for { | ||
120 | select { | ||
121 | case <-t.C: | ||
122 | if nets, err := f.NetboxClient.GetPrefixesWithTag(f.Tag); err != nil { | ||
123 | f.Logger.Errorf("Error refreshing netbox prefixes for IP filter: %w", err) | ||
124 | netboxFilterFailures.Inc() | ||
125 | } else { | ||
126 | f.Logger.Debugf("Got prefixes: %s", nets) | ||
127 | f.f.UpdateRanges(nets) | ||
128 | } | ||
129 | case <-c.Done(): | ||
130 | f.Logger.Info("Shutting down netbox IP address filter refresh loop") | ||
131 | return nil | ||
37 | } | 132 | } |
38 | } | 133 | } |
39 | } | 134 | } |
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 @@ | |||
1 | package netbox | ||
2 | |||
3 | import ( | ||
4 | "encoding/json" | ||
5 | "fmt" | ||
6 | "io" | ||
7 | "net" | ||
8 | "net/http" | ||
9 | "net/url" | ||
10 | "strconv" | ||
11 | |||
12 | "code.crute.us/mcrute/golib/vault" | ||
13 | ) | ||
14 | |||
15 | type NetboxClient interface { | ||
16 | GetSitePrefixesWithTag(site string, tag string) ([]*net.IPNet, error) | ||
17 | GetPrefixesWithTag(tag string) ([]*net.IPNet, error) | ||
18 | } | ||
19 | |||
20 | func NewNetboxClientDefault() NetboxClient { | ||
21 | return &netboxClient{ | ||
22 | endpoint: "https://netbox.crute.me", | ||
23 | vaultMaterial: "infra/netbox-readonly", | ||
24 | } | ||
25 | } | ||
26 | |||
27 | func NewNetboxClient(endpoint string, vaultMaterial string) NetboxClient { | ||
28 | return &netboxClient{ | ||
29 | endpoint: endpoint, | ||
30 | vaultMaterial: vaultMaterial, | ||
31 | } | ||
32 | } | ||
33 | |||
34 | type netboxClient struct { | ||
35 | endpoint string | ||
36 | vaultMaterial string | ||
37 | } | ||
38 | |||
39 | func (c *netboxClient) makeRequestRaw(method, u string, ib io.Reader, o interface{}) error { | ||
40 | apiKey, err := vault.GetVaultApiKey(c.vaultMaterial) | ||
41 | if err != nil { | ||
42 | return err | ||
43 | } | ||
44 | |||
45 | req, err := http.NewRequest(method, u, ib) | ||
46 | if err != nil { | ||
47 | return err | ||
48 | } | ||
49 | req.Header.Add("Authorization", fmt.Sprintf("Token %s", apiKey)) | ||
50 | |||
51 | res, err := http.DefaultClient.Do(req) | ||
52 | if err != nil { | ||
53 | return err | ||
54 | } | ||
55 | defer res.Body.Close() | ||
56 | |||
57 | if err = json.NewDecoder(res.Body).Decode(o); err != nil { | ||
58 | return err | ||
59 | } | ||
60 | |||
61 | return nil | ||
62 | } | ||
63 | |||
64 | func (c *netboxClient) makeRequest(method, path string, q url.Values, ib io.Reader, o interface{}) error { | ||
65 | u, err := url.Parse(c.endpoint) | ||
66 | if err != nil { | ||
67 | return err | ||
68 | } | ||
69 | u.Path = path | ||
70 | u.RawQuery = q.Encode() | ||
71 | |||
72 | return c.makeRequestRaw(method, u.String(), ib, o) | ||
73 | } | ||
74 | |||
75 | func (c *netboxClient) GetSitePrefixesWithTag(site string, tag string) ([]*net.IPNet, error) { | ||
76 | s, err := c.resolveSiteNameToId(site) | ||
77 | if err != nil { | ||
78 | return nil, err | ||
79 | } | ||
80 | |||
81 | out := []*net.IPNet{} | ||
82 | |||
83 | q := url.Values{} | ||
84 | q.Add("site_id", strconv.Itoa(s)) | ||
85 | q.Add("tag", tag) | ||
86 | |||
87 | page := &PrefixList{} | ||
88 | if err := c.makeRequest(http.MethodGet, "/api/ipam/prefixes/", q, nil, page); err != nil { | ||
89 | return nil, err | ||
90 | } | ||
91 | |||
92 | for _, r := range page.Results { | ||
93 | _, ipnet, err := net.ParseCIDR(r.Prefix) | ||
94 | if err != nil { | ||
95 | return nil, err | ||
96 | } | ||
97 | out = append(out, ipnet) | ||
98 | } | ||
99 | |||
100 | for page.Next != "" { | ||
101 | page = &PrefixList{} | ||
102 | if err := c.makeRequestRaw(http.MethodGet, page.Next, nil, page); err != nil { | ||
103 | return nil, err | ||
104 | } | ||
105 | |||
106 | for _, r := range page.Results { | ||
107 | _, ipnet, err := net.ParseCIDR(r.Prefix) | ||
108 | if err != nil { | ||
109 | return nil, err | ||
110 | } | ||
111 | out = append(out, ipnet) | ||
112 | } | ||
113 | } | ||
114 | |||
115 | return out, nil | ||
116 | } | ||
117 | |||
118 | func (c *netboxClient) GetPrefixesWithTag(tag string) ([]*net.IPNet, error) { | ||
119 | out := []*net.IPNet{} | ||
120 | |||
121 | q := url.Values{} | ||
122 | q.Add("tag", tag) | ||
123 | |||
124 | page := &PrefixList{} | ||
125 | if err := c.makeRequest(http.MethodGet, "/api/ipam/prefixes/", q, nil, page); err != nil { | ||
126 | return nil, err | ||
127 | } | ||
128 | |||
129 | for _, r := range page.Results { | ||
130 | _, ipnet, err := net.ParseCIDR(r.Prefix) | ||
131 | if err != nil { | ||
132 | return nil, err | ||
133 | } | ||
134 | out = append(out, ipnet) | ||
135 | } | ||
136 | |||
137 | for page.Next != "" { | ||
138 | page = &PrefixList{} | ||
139 | if err := c.makeRequestRaw(http.MethodGet, page.Next, nil, page); err != nil { | ||
140 | return nil, err | ||
141 | } | ||
142 | |||
143 | for _, r := range page.Results { | ||
144 | _, ipnet, err := net.ParseCIDR(r.Prefix) | ||
145 | if err != nil { | ||
146 | return nil, err | ||
147 | } | ||
148 | out = append(out, ipnet) | ||
149 | } | ||
150 | } | ||
151 | |||
152 | return out, nil | ||
153 | } | ||
154 | |||
155 | func (c *netboxClient) resolveSiteNameToId(s string) (int, error) { | ||
156 | q := url.Values{} | ||
157 | q.Add("name", s) | ||
158 | |||
159 | out := &SiteList{} | ||
160 | if err := c.makeRequest(http.MethodGet, "/api/dcim/sites/", q, nil, out); err != nil { | ||
161 | return 0, err | ||
162 | } | ||
163 | |||
164 | if len(out.Results) == 0 { | ||
165 | return 0, fmt.Errorf("resolveSiteNameToId: no results returned from netbox") | ||
166 | } | ||
167 | |||
168 | return out.Results[0].ID, nil | ||
169 | } | ||
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 @@ | |||
1 | package netbox | ||
2 | |||
3 | type LabeledInt struct { | ||
4 | Value int `json:"value"` | ||
5 | Label string `json:"label"` | ||
6 | } | ||
7 | |||
8 | type LabeledString struct { | ||
9 | Value string `json:"value"` | ||
10 | Label string `json:"label"` | ||
11 | } | ||
12 | |||
13 | type Role struct { | ||
14 | ID int `json:"ID"` | ||
15 | Url string `json:"url"` | ||
16 | Display string `json:"display"` | ||
17 | Name string `json:"name"` | ||
18 | Slug string `json:"slub"` | ||
19 | } | ||
20 | |||
21 | type Tag struct { | ||
22 | ID int `json:"id"` | ||
23 | Url string `json:"url"` | ||
24 | Display string `json:"display"` | ||
25 | Name string `json:"name"` | ||
26 | Slug string `json:"slug"` | ||
27 | Color string `json:"color"` | ||
28 | } | ||
29 | |||
30 | type SiteList struct { | ||
31 | Count int `json:"count"` | ||
32 | Next string `json:"next"` | ||
33 | Previous string `json:"previous"` | ||
34 | Results []*Site `json:"results"` | ||
35 | } | ||
36 | |||
37 | type Site struct { | ||
38 | ID int `json:"id"` | ||
39 | Url string `json:"url"` | ||
40 | Display string `json:"display"` | ||
41 | Name string `json:"name"` | ||
42 | Slug string `json:"slug"` | ||
43 | Facility string `json:"facility"` | ||
44 | Description string `json:"description"` | ||
45 | Timezone string `json:"time_zone"` | ||
46 | ASN int `json:"asn"` | ||
47 | Status LabeledString `json:"status"` | ||
48 | } | ||
49 | |||
50 | type PrefixList struct { | ||
51 | Count int `json:"count"` | ||
52 | Next string `json:"next"` | ||
53 | Previous string `json:"previous"` | ||
54 | Results []*Prefix `json:"results"` | ||
55 | } | ||
56 | |||
57 | type Prefix struct { | ||
58 | ID int `json:"ID"` | ||
59 | Url string `json:"url"` | ||
60 | Display string `json:"display"` | ||
61 | Prefix string `json:"prefix"` | ||
62 | IsPool bool `json:"is_pool"` | ||
63 | Description string `json:"description"` | ||
64 | Created string `json:"created"` | ||
65 | LastUpdated string `json:"last_updated"` | ||
66 | Children int `json:"children"` | ||
67 | Depth int `json:"_depth"` | ||
68 | Family LabeledInt `json:"family"` | ||
69 | Status LabeledString `json:"status"` | ||
70 | Site *Site `json:"site"` | ||
71 | Role *Role `json:"role"` | ||
72 | Tags []*Tag `json:"tags"` | ||
73 | CustomFields map[string]interface{} `json:"custom_fields"` | ||
74 | } | ||