From 72ae0ccd8bbd139dfe48a7c0419c61bd5d6d5543 Mon Sep 17 00:00:00 2001 From: Jon Hadfield Date: Mon, 13 Apr 2020 17:52:59 +0100 Subject: [PATCH] add pihole module. --- app/widget_maker.go | 4 + go.mod | 1 - go.sum | 17 +- modules/pihole/client.go | 363 +++++++++++++++++++++++++++++++++++++ modules/pihole/keyboard.go | 8 + modules/pihole/settings.go | 38 ++++ modules/pihole/view.go | 230 +++++++++++++++++++++++ modules/pihole/widget.go | 89 +++++++++ 8 files changed, 733 insertions(+), 17 deletions(-) create mode 100644 modules/pihole/client.go create mode 100644 modules/pihole/keyboard.go create mode 100644 modules/pihole/settings.go create mode 100644 modules/pihole/view.go create mode 100644 modules/pihole/widget.go diff --git a/app/widget_maker.go b/app/widget_maker.go index 529cb3d5..44210ce4 100644 --- a/app/widget_maker.go +++ b/app/widget_maker.go @@ -46,6 +46,7 @@ import ( "github.com/wtfutil/wtf/modules/newrelic" "github.com/wtfutil/wtf/modules/opsgenie" "github.com/wtfutil/wtf/modules/pagerduty" + "github.com/wtfutil/wtf/modules/pihole" "github.com/wtfutil/wtf/modules/pocket" "github.com/wtfutil/wtf/modules/power" "github.com/wtfutil/wtf/modules/resourceusage" @@ -226,6 +227,9 @@ func MakeWidget( case "pagerduty": settings := pagerduty.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = pagerduty.NewWidget(app, settings) + case "pihole": + settings := pihole.NewSettingsFromYAML(moduleName, moduleConfig, config) + widget = pihole.NewWidget(app, pages, settings) case "power": settings := power.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = power.NewWidget(app, settings) diff --git a/go.mod b/go.mod index 2b685093..ec5ed1dc 100644 --- a/go.mod +++ b/go.mod @@ -30,7 +30,6 @@ require ( github.com/godbus/dbus v4.1.0+incompatible // indirect github.com/google/go-github/v26 v26.1.3 github.com/gophercloud/gophercloud v0.5.0 // indirect - github.com/hashicorp/go-cleanhttp v0.5.1 // indirect github.com/hekmon/cunits v2.0.1+incompatible // indirect github.com/hekmon/transmissionrpc v0.0.0-20190525133028-1d589625bacd github.com/imdario/mergo v0.3.8 // indirect diff --git a/go.sum b/go.sum index 5419f7b0..8f33bd83 100644 --- a/go.sum +++ b/go.sum @@ -54,8 +54,6 @@ github.com/adlio/trello v1.7.0 h1:syLRJ27wCM8URf7zOBWGr981cG+dpmLSyMqjEoQc+4g= github.com/adlio/trello v1.7.0/go.mod h1:l2068AhUuUuQ9Vsb95ECMueHThYyAj4e85lWPmr2/LE= github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U= github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI= -github.com/alecthomas/chroma v0.7.1 h1:G1i02OhUbRi2nJxcNkwJaY/J1gHXj9tt72qN6ZouLFQ= -github.com/alecthomas/chroma v0.7.1/go.mod h1:gHw09mkX1Qp80JlYbmN9L3+4R5o6DJJ3GRShh+AICNc= github.com/alecthomas/chroma v0.7.2 h1:B76NU/zbQYIUhUowbi4fmvREmDUJLsUzKWTZmQd3ABY= github.com/alecthomas/chroma v0.7.2/go.mod h1:fv5SzZPFJbwp2NXJWpFIX7DZS4HgV1K4ew4Pc2OZD9s= github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo= @@ -132,10 +130,6 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= -github.com/digitalocean/godo v1.32.0 h1:ljfhYi/IqDYiZcBYV7nUPzw1Q7NlmPSFDtI69UeRThk= -github.com/digitalocean/godo v1.32.0/go.mod h1:iJnN9rVu6K5LioLxLimlq0uRI+y/eAQjROUmeU/r0hY= -github.com/digitalocean/godo v1.33.1 h1:W5e7EgW8EVOM+ycZ6z+LU/WTZrxohIkgxbVwLOU/Q6s= -github.com/digitalocean/godo v1.33.1/go.mod h1:gfLm3JSupWD9V/ibQygXWW3IVz7hranzckH5UimhZsI= github.com/digitalocean/godo v1.34.0 h1:OXJhLLJS2VTB5SziTyCq8valKVZ0uBHCFQsDW3/HF78= github.com/digitalocean/godo v1.34.0/go.mod h1:gfLm3JSupWD9V/ibQygXWW3IVz7hranzckH5UimhZsI= github.com/dlclark/regexp2 v1.1.6 h1:CqB4MjHw0MFCDj+PHHjiESmHX+N7t0tJzKvC6M97BRg= @@ -301,6 +295,7 @@ github.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce/go.mod h1:YH+1FK github.com/hashicorp/go-cleanhttp v0.0.0-20160407174126-ad28ea4487f0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.1 h1:dH3aiDG9Jvb5r5+bYHsikaOUIpcM0xvgMXVoDkXMzJM= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-hclog v0.9.2 h1:CG6TE5H9/JXsFWJCfoIVpKFIkFe6ysEuHirp4DxCsHI= github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ= github.com/hashicorp/go-immutable-radix v1.0.0 h1:AKDB1HM5PWEA7i4nhcpwOrO2byshxBjXVn/J/3+z5/0= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= @@ -473,8 +468,6 @@ github.com/nbutton23/zxcvbn-go v0.0.0-20180912185939-ae427f1e4c1d h1:AREM5mwr4u1 github.com/nbutton23/zxcvbn-go v0.0.0-20180912185939-ae427f1e4c1d/go.mod h1:o96djdrsSGy3AWPyBgZMAGfxZNfgntdJG+11KU4QvbU= github.com/ncw/swift v0.0.0-20171019114456-c95c6e5c2d1a h1:SAjW6pL/9NssyKM1Qvyy5/V4kR3z76qlTbaqJLixhP4= github.com/ncw/swift v0.0.0-20171019114456-c95c6e5c2d1a/go.mod h1:23YIA4yWVnGwv2dQlN4bB7egfYX6YLn0Yo/S6zZO/ZM= -github.com/nicklaw5/helix v0.5.7 h1:DvNyoKkuLYrqZv5/yugL18Ud99UeQoXzzAsg4OwU8uY= -github.com/nicklaw5/helix v0.5.7/go.mod h1:nRcok4VLg8ONQYW/iXBZ24wcfiJjTlDbhgk0ZatOrUY= github.com/nicklaw5/helix v0.5.8 h1:RG1vV/XDI6Kc0V/KvoUzRb3Q/7rmAQvVuisfxxYg1ZY= github.com/nicklaw5/helix v0.5.8/go.mod h1:nRcok4VLg8ONQYW/iXBZ24wcfiJjTlDbhgk0ZatOrUY= github.com/nsf/termbox-go v0.0.0-20190817171036-93860e161317/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ= @@ -591,8 +584,6 @@ github.com/sethgrid/pester v0.0.0-20171127025028-760f8913c048/go.mod h1:Ad7IjTpv github.com/sguiheux/go-coverage v0.0.0-20190710153556-287b082a7197 h1:qu90yDtRE5WEfRT5mn9v0Xz9RaopLguhbPwZKx4dHq8= github.com/sguiheux/go-coverage v0.0.0-20190710153556-287b082a7197/go.mod h1:0hhKrsUsoT7yvxwNGKa+TSYNA26DNWMqReeZEQq/9FI= github.com/shirou/gopsutil v0.0.0-20170406131756-e49a95f3d5f8/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= -github.com/shirou/gopsutil v2.20.2+incompatible h1:ucK79BhBpgqQxPASyS2cu9HX8cfDVljBN1WWFvbNvgY= -github.com/shirou/gopsutil v2.20.2+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/shirou/gopsutil v2.20.3+incompatible h1:0JVooMPsT7A7HqEYdydp/OfjSOYSjhXV7w1hkKj/NPQ= github.com/shirou/gopsutil v2.20.3+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= @@ -653,10 +644,6 @@ github.com/wtfutil/spotigopher v0.0.0-20191127141047-7d8168fe103a/go.mod h1:AlO4 github.com/wtfutil/todoist v0.0.2-0.20191216004217-0ec29ceda61a h1:nD8ALd4TSo+zPHK5MqQWFj01G8fMMHFfC3rWvoq/9JA= github.com/wtfutil/todoist v0.0.2-0.20191216004217-0ec29ceda61a/go.mod h1:YuuGLJSsTK6DGBD5Zaf3J8LSMfpEC2WtzYPey3XVOdI= github.com/xanzy/go-gitlab v0.15.0/go.mod h1:8zdQa/ri1dfn8eS3Ir1SyfvOKlw7WBJ8DVThkpGiXrs= -github.com/xanzy/go-gitlab v0.28.0 h1:nsyjDVvBrP4KRXEN4b1m1ewiqmTNL4BOWW041nKGV7k= -github.com/xanzy/go-gitlab v0.28.0/go.mod h1:t4Bmvnxj7k37S4Y17lfLx+nLqkf/oQwT2HagfWKv5Og= -github.com/xanzy/go-gitlab v0.29.0 h1:9tMvAkG746eIlzcdpnRgpcKPA1woUDmldMIjR/E5OWM= -github.com/xanzy/go-gitlab v0.29.0/go.mod h1:sPLojNBn68fMUWSxIJtdVVIP8uSBYqesTfDUseX11Ug= github.com/xanzy/go-gitlab v0.31.0 h1:+nHztQuCXGSMluKe5Q9IRaPdz6tO8O0gMkQ0vqGpiBk= github.com/xanzy/go-gitlab v0.31.0/go.mod h1:sPLojNBn68fMUWSxIJtdVVIP8uSBYqesTfDUseX11Ug= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= @@ -801,8 +788,6 @@ google.golang.org/api v0.3.2/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMt google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= -google.golang.org/api v0.20.0 h1:jz2KixHX7EcCPiQrySzPdnYT7DbINAypCqKZ1Z7GM40= -google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/api v0.21.0 h1:zS+Q/CJJnVlXpXQVIz+lH0ZT2lBuT2ac7XD8Y/3w6hY= google.golang.org/api v0.21.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= diff --git a/modules/pihole/client.go b/modules/pihole/client.go new file mode 100644 index 00000000..32821150 --- /dev/null +++ b/modules/pihole/client.go @@ -0,0 +1,363 @@ +package pihole + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + url2 "net/url" + "regexp" + "strconv" + "strings" + "time" +) + +type Status struct { + DomainsBeingBlocked string `json:"domains_being_blocked"` + DNSQueriesToday string `json:"dns_queries_today"` + AdsBlockedToday string `json:"ads_blocked_today"` + AdsPercentageToday string `json:"ads_percentage_today"` + UniqueDomains string `json:"unique_domains"` + QueriesForwarded string `json:"queries_forwarded"` + QueriesCached string `json:"queries_cached"` + Status string `json:"status"` + GravityLastUpdated struct { + Relative struct { + Days string `json:"days"` + Hours string `json:"hours"` + Minutes string `json:"minutes"` + } + } `json:"gravity_last_updated"` +} + +func getStatus(c http.Client, apiURL string) (status Status, err error) { + var req *http.Request + + var url *url2.URL + + if url, err = url2.Parse(apiURL); err != nil { + return status, fmt.Errorf(" failed to parse API URL\n %s\n", parseError(err)) + } + + var query url2.Values + + if query, err = url2.ParseQuery(url.RawQuery); err != nil { + return status, fmt.Errorf(" failed to parse query\n %s\n", parseError(err)) + } + + query.Add("summary", "") + + url.RawQuery = query.Encode() + if req, err = http.NewRequest("GET", url.String(), nil); err != nil { + return status, fmt.Errorf(" failed to create request\n %s\n", parseError(err)) + } + + var resp *http.Response + + if resp, err = c.Do(req); err != nil || resp == nil { + return status, fmt.Errorf(" failed to connect to Pi-hole server\n %s\n", parseError(err)) + } + + defer func() { + if err := resp.Body.Close(); err != nil { + return + } + }() + + if resp.StatusCode >= http.StatusBadRequest { + return status, fmt.Errorf(" failed to retrieve version from Pi-hole server\n http status code: %d", + resp.StatusCode) + } + + var rBody []byte + + if rBody, err = ioutil.ReadAll(resp.Body); err != nil { + return status, fmt.Errorf(" failed to read status response\n") + } + + if err = json.Unmarshal(rBody, &status); err != nil { + return status, fmt.Errorf(" failed to retrieve top items: check provided api URL and token\n %s\n\n", + parseError(err)) + } + + return status, err +} + +type TopItems struct { + TopQueries map[string]int `json:"top_queries"` + TopAds map[string]int `json:"top_ads"` +} + +func getTopItems(c http.Client, settings *Settings) (ti TopItems, err error) { + var req *http.Request + + var url *url2.URL + + if url, err = url2.Parse(settings.apiUrl); err != nil { + return ti, fmt.Errorf(" failed to parse API URL\n %s\n", parseError(err)) + } + + var query url2.Values + + if query, err = url2.ParseQuery(url.RawQuery); err != nil { + return ti, fmt.Errorf(" failed to parse query\n %s\n", parseError(err)) + } + + query.Add("auth", settings.token) + query.Add("topItems", strconv.Itoa(settings.showTopItems)) + + url.RawQuery = query.Encode() + + req, err = http.NewRequest("GET", url.String(), nil) + if err != nil { + return ti, fmt.Errorf(" failed to create request\n %s\n", parseError(err)) + } + + var resp *http.Response + + if resp, err = c.Do(req); err != nil || resp == nil { + return ti, fmt.Errorf(" failed to connect to Pi-hole server\n %s\n", parseError(err)) + } + + defer func() { + if err := resp.Body.Close(); err != nil { + return + } + }() + + if resp.StatusCode >= http.StatusBadRequest { + return ti, fmt.Errorf(" failed to retrieve version from Pi-hole server\n http status code: %d", + resp.StatusCode) + } + + var rBody []byte + + rBody, err = ioutil.ReadAll(resp.Body) + if err = json.Unmarshal(rBody, &ti); err != nil { + return ti, fmt.Errorf(" failed to retrieve top items: check provided api URL and token\n %s\n\n", + parseError(err)) + } + + return ti, err +} + +type TopClients struct { + TopSources map[string]int `json:"top_sources"` +} + +// parseError removes any token from output and ensures a non-nil response +func parseError(err error) string { + if err == nil { + return "unknown error" + } + + var re = regexp.MustCompile(`auth=[a-zA-Z0-9]*`) + + return re.ReplaceAllString(err.Error(), "auth=") +} + +func getTopClients(c http.Client, settings *Settings) (tc TopClients, err error) { + var req *http.Request + + var url *url2.URL + + if url, err = url2.Parse(settings.apiUrl); err != nil { + return tc, fmt.Errorf(" failed to parse API URL\n %s\n", parseError(err)) + } + + var query url2.Values + + if query, err = url2.ParseQuery(url.RawQuery); err != nil { + return tc, fmt.Errorf(" failed to parse query\n %s\n", parseError(err)) + } + + query.Add("topClients", strconv.Itoa(settings.showTopClients)) + query.Add("auth", settings.token) + url.RawQuery = query.Encode() + + if req, err = http.NewRequest("GET", url.String(), nil); err != nil { + return tc, fmt.Errorf(" failed to create request\n %s\n", parseError(err)) + } + + var resp *http.Response + + if resp, err = c.Do(req); err != nil || resp == nil { + return tc, fmt.Errorf(" failed to connect to Pi-hole server\n %s\n", parseError(err)) + } + + defer func() { + if err := resp.Body.Close(); err != nil { + return + } + }() + + if resp.StatusCode >= http.StatusBadRequest { + return tc, fmt.Errorf(" failed to retrieve version from Pi-hole server\n http status code: %d", + resp.StatusCode) + } + + var rBody []byte + + if rBody, err = ioutil.ReadAll(resp.Body); err != nil { + return tc, fmt.Errorf(" failed to read top clients response\n %s\n", parseError(err)) + } + + if err = json.Unmarshal(rBody, &tc); err != nil { + return tc, fmt.Errorf(" failed to retrieve top clients: check provided api URL and token\n %s\n\n", + parseError(err)) + } + + return tc, err +} + +type QueryTypes struct { + QueryTypes map[string]float32 `json:"querytypes"` +} + +func getQueryTypes(c http.Client, settings *Settings) (qt QueryTypes, err error) { + var req *http.Request + + var url *url2.URL + + if url, err = url2.Parse(settings.apiUrl); err != nil { + return qt, fmt.Errorf(" failed to parse API URL\n %s\n", parseError(err)) + } + + var query url2.Values + + if query, err = url2.ParseQuery(url.RawQuery); err != nil { + return qt, fmt.Errorf(" failed to parse query\n %s\n", parseError(err)) + } + + query.Add("getQueryTypes", strconv.Itoa(settings.showTopClients)) + query.Add("auth", settings.token) + + url.RawQuery = query.Encode() + + if req, err = http.NewRequest("GET", url.String(), nil); err != nil { + return qt, fmt.Errorf(" failed to create request\n %s\n", parseError(err)) + } + + var resp *http.Response + + if resp, err = c.Do(req); err != nil || resp == nil { + return qt, fmt.Errorf(" failed to connect to Pi-hole server\n %s\n", parseError(err)) + } + + defer func() { + if err := resp.Body.Close(); err != nil { + return + } + }() + + if resp.StatusCode >= http.StatusBadRequest { + return qt, fmt.Errorf(" failed to retrieve version from Pi-hole server\n http status code: %d", + resp.StatusCode) + } + + var rBody []byte + + if rBody, err = ioutil.ReadAll(resp.Body); err != nil { + return qt, fmt.Errorf(" failed to read top clients response\n %s\n", parseError(err)) + } + + if err = json.Unmarshal(rBody, &qt); err != nil { + return qt, fmt.Errorf(" failed to parse query types response\n %s\n", parseError(err)) + } + + return qt, err +} + +func checkServer(c http.Client, apiURL string) error { + var err error + + var req *http.Request + + var url *url2.URL + + if url, err = url2.Parse(apiURL); err != nil { + return fmt.Errorf(" failed to parse API URL\n %s\n", parseError(err)) + } + + if url.Host == "" { + return fmt.Errorf(" please specify 'apiUrl' in Pi-hole settings, e.g.\n apiUrl: http://:/admin/api.php") + } + + if req, err = http.NewRequest("GET", fmt.Sprintf("%s?version", + url.String()), nil); err != nil { + return fmt.Errorf("invalid request: %s\n", parseError(err)) + } + + var resp *http.Response + + if resp, err = c.Do(req); err != nil { + return fmt.Errorf(" failed to connect to Pi-hole server\n %s\n", parseError(err)) + } + + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode >= http.StatusBadRequest { + return fmt.Errorf(" failed to retrieve version from Pi-hole server\n http status code: %d", + resp.StatusCode) + } + + var vResp struct { + Version int `json:"version"` + } + + var rBody []byte + + if rBody, err = ioutil.ReadAll(resp.Body); err != nil { + return fmt.Errorf(" Pi-hole server failed to respond\n %s\n", parseError(err)) + } + + if err = json.Unmarshal(rBody, &vResp); err != nil { + return fmt.Errorf(" invalid response returned from Pi-hole Server\n %s\n", parseError(err)) + } + + if vResp.Version != 3 { + return fmt.Errorf(" only Pi-hole API version 3 is supported\n version %d was detected", vResp.Version) + } + + return err +} + +func (widget *Widget) adblockSwitch(action string) { + var req *http.Request + + var url *url2.URL + url, _ = url2.Parse(widget.settings.apiUrl) + + var query url2.Values + query, _ = url2.ParseQuery(url.RawQuery) + + query.Add(strings.ToLower(action), "") + query.Add("auth", widget.settings.token) + + url.RawQuery = query.Encode() + + req, _ = http.NewRequest("GET", url.String(), nil) + + c := getClient() + resp, _ := c.Do(req) + + defer func() { + _ = resp.Body.Close() + }() + + widget.Refresh() +} + +func getClient() http.Client { + return http.Client{ + Transport: &http.Transport{ + TLSHandshakeTimeout: 5 * time.Second, + DisableKeepAlives: false, + DisableCompression: false, + ResponseHeaderTimeout: 20 * time.Second, + }, + Timeout: 21 * time.Second, + } +} diff --git a/modules/pihole/keyboard.go b/modules/pihole/keyboard.go new file mode 100644 index 00000000..36309793 --- /dev/null +++ b/modules/pihole/keyboard.go @@ -0,0 +1,8 @@ +package pihole + +func (widget *Widget) initializeKeyboardControls() { + widget.InitializeCommonControls(widget.Refresh) + + widget.SetKeyboardChar("d", widget.disable, "disable Pi-hole") + widget.SetKeyboardChar("e", widget.enable, "enable Pi-hole") +} diff --git a/modules/pihole/settings.go b/modules/pihole/settings.go new file mode 100644 index 00000000..5f25ca46 --- /dev/null +++ b/modules/pihole/settings.go @@ -0,0 +1,38 @@ +package pihole + +import ( + "github.com/olebedev/config" + "github.com/wtfutil/wtf/cfg" +) + +const ( + defaultFocusable = true + defaultTitle = "Pi-hole" +) + +type Settings struct { + common *cfg.Common + wrapText bool + apiUrl string + token string + showTopItems int + showTopClients int + maxClientWidth int + maxDomainWidth int + showSummary bool +} + +func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { + settings := Settings{ + common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), + apiUrl: ymlConfig.UString("apiUrl"), + token: ymlConfig.UString("token"), + showSummary: ymlConfig.UBool("showSummary", true), + showTopItems: ymlConfig.UInt("showTopItems", 5), + showTopClients: ymlConfig.UInt("showTopClients", 5), + maxClientWidth: ymlConfig.UInt("maxClientWidth", 20), + maxDomainWidth: ymlConfig.UInt("maxDomainWidth", 20), + } + + return &settings +} diff --git a/modules/pihole/view.go b/modules/pihole/view.go new file mode 100644 index 00000000..21e738f1 --- /dev/null +++ b/modules/pihole/view.go @@ -0,0 +1,230 @@ +package pihole + +import ( + "bytes" + "fmt" + "io" + "net/http" + "sort" + "strconv" + "strings" + + "github.com/olekukonko/tablewriter" +) + +func getSummaryView(c http.Client, settings *Settings) string { + var err error + + var s Status + + s, err = getStatus(c, settings.apiUrl) + if err != nil { + return err.Error() + } + + var sb strings.Builder + + buf := new(bytes.Buffer) + + switch strings.ToLower(s.Status) { + case "disabled": + sb.WriteString(" [white]Status [red]DISABLED\n") + case "enabled": + sb.WriteString(" [white]Status [green]ENABLED\n") + default: + sb.WriteString(" [white]Status [yellow]UNKNOWN\n") + } + + summaryTable := createTable([]string{}, buf) + summaryTable.Append([]string{"Domain blocklist", s.DomainsBeingBlocked, "Queries today", s.DNSQueriesToday}) + summaryTable.Append([]string{"Ads blocked today", fmt.Sprintf("%s (%s%%)", s.AdsBlockedToday, s.AdsPercentageToday), "Cached queries", s.QueriesCached}) + summaryTable.Append([]string{"Blocklist Age", fmt.Sprintf("%sd %sh %sm", s.GravityLastUpdated.Relative.Days, + s.GravityLastUpdated.Relative.Hours, s.GravityLastUpdated.Relative.Minutes), "Forwarded queries", s.QueriesForwarded}) + summaryTable.Render() + + sb.WriteString(buf.String()) + + return sb.String() +} + +func getTopItemsView(c http.Client, settings *Settings) string { + var err error + + var ti TopItems + + ti, err = getTopItems(c, settings) + if err != nil { + return err.Error() + } + + buf := new(bytes.Buffer) + + var sb strings.Builder + + tiTable := createTable([]string{"Top Queries", "", "Top Ads", ""}, buf) + + largest := len(ti.TopAds) + if len(ti.TopQueries) > largest { + largest = len(ti.TopQueries) + } + + sortedTiQueries := sortMapByIntVal(ti.TopQueries) + + sortedTiAds := sortMapByIntVal(ti.TopAds) + + for x := 0; x < largest; x++ { + tiQVal := []string{"", ""} + if len(sortedTiQueries) > x { + tiQVal = []string{shorten(sortedTiQueries[x][0], settings.maxDomainWidth), sortedTiQueries[x][1]} + } + + tiAVal := []string{"", ""} + + if len(sortedTiAds) > x { + tiAVal = []string{shorten(sortedTiAds[x][0], settings.maxDomainWidth), sortedTiAds[x][1]} + } + + tiTable.Append([]string{tiQVal[0], tiQVal[1], tiAVal[0], tiAVal[1]}) + } + + tiTable.Render() + sb.WriteString(buf.String()) + + return sb.String() +} + +func getTopClientsView(c http.Client, settings *Settings) string { + tc, err := getTopClients(c, settings) + if err != nil { + return err.Error() + } + + var tq QueryTypes + + tq, err = getQueryTypes(c, settings) + if err != nil { + return err.Error() + } + + buf := new(bytes.Buffer) + + tcTable := createTable([]string{"Top Clients", "", "Top Query Types", ""}, buf) + + sortedTcQueries := sortMapByIntVal(tc.TopSources) + + sortedTopQT := sortMapByFloatVal(tq.QueryTypes) + + largest := len(tc.TopSources) + + if len(tq.QueryTypes) > largest { + largest = len(tq.QueryTypes) + } + + if settings.showTopClients < largest { + largest = settings.showTopClients + } + + for x := 0; x < largest; x++ { + tcVal := []string{"", ""} + + if len(sortedTcQueries) > x { + tcVal = []string{sortedTcQueries[x][0], sortedTcQueries[x][1]} + } + + tqtVal := []string{"", ""} + + if len(sortedTopQT) > x && sortedTopQT[x][0] != "" { + tqtVal = []string{sortedTopQT[x][0], sortedTopQT[x][1] + "%"} + } + + tcTable.Append([]string{tcVal[0], tcVal[1], tqtVal[0], tqtVal[1]}) + } + + tcTable.Render() + + var sb strings.Builder + + sb.WriteString(buf.String()) + + return sb.String() +} + +func shorten(s string, limit int) string { + if len(s) > limit { + return s[:limit] + "..." + } + + return s +} + +func createTable(header []string, buf io.Writer) *tablewriter.Table { + table := tablewriter.NewWriter(buf) + + if len(header) != 0 { + table.SetHeader(header) + table.SetHeaderLine(false) + table.SetHeaderAlignment(0) + } + + table.SetAutoWrapText(false) + table.SetAutoFormatHeaders(true) + table.SetHeaderAlignment(tablewriter.ALIGN_LEFT) + table.SetAlignment(tablewriter.ALIGN_LEFT) + table.SetBorder(true) + table.SetCenterSeparator("") + table.SetColumnSeparator("") + table.SetRowSeparator("") + table.SetTablePadding(" ") + table.SetNoWhiteSpace(false) + + return table +} + +func sortMapByIntVal(m map[string]int) (sorted [][]string) { + type kv struct { + Key string + Value int + } + + ss := make([]kv, len(m)) + for k, v := range m { + ss = append(ss, kv{k, v}) + } + + sort.Slice(ss, func(i, j int) bool { + return ss[i].Value > ss[j].Value + }) + + for _, kv := range ss { + sorted = append(sorted, []string{kv.Key, strconv.Itoa(kv.Value)}) + } + + return +} + +func sortMapByFloatVal(m map[string]float32) (sorted [][]string) { + type kv struct { + Key string + Value float32 + } + + ss := make([]kv, len(m)) + + for k, v := range m { + if k == "" || v == 0.00 { + continue + } + + ss = append(ss, kv{k, v}) + } + + sort.Slice(ss, func(i, j int) bool { + return ss[i].Value > ss[j].Value + }) + + for _, kv := range ss { + sorted = append(sorted, []string{kv.Key, fmt.Sprintf("%.2f", kv.Value)}) + } + + return +} diff --git a/modules/pihole/widget.go b/modules/pihole/widget.go new file mode 100644 index 00000000..4f108c17 --- /dev/null +++ b/modules/pihole/widget.go @@ -0,0 +1,89 @@ +package pihole + +import ( + "strings" + + "github.com/rivo/tview" + "github.com/wtfutil/wtf/view" +) + +type Widget struct { + view.KeyboardWidget + view.MultiSourceWidget + view.TextWidget + + settings *Settings +} + +// NewWidget creates a new instance of a widget +//func NewWidget(app *tview.Application, settings *Settings) *Widget { +func NewWidget(app *tview.Application, pages *tview.Pages, settings *Settings) *Widget { + widget := Widget{ + KeyboardWidget: view.NewKeyboardWidget(app, pages, settings.common), + TextWidget: view.NewTextWidget(app, settings.common), + settings: settings, + } + + widget.settings.common.RefreshInterval = 30 + widget.View.SetInputCapture(widget.InputCapture) + widget.initializeKeyboardControls() + widget.SetDisplayFunction(widget.Refresh) + widget.View.SetWordWrap(true) + widget.View.SetWrap(settings.wrapText) + + widget.KeyboardWidget.SetView(widget.View) + + return &widget +} + +/* -------------------- Exported Functions -------------------- */ + +func (widget *Widget) Refresh() { + if widget.Disabled() { + return + } + + widget.Redraw(widget.content) +} + +func (widget *Widget) HelpText() string { + return widget.KeyboardWidget.HelpText() +} + +/* -------------------- Unexported Functions -------------------- */ + +func (widget *Widget) content() (string, string, bool) { + title := widget.CommonSettings().Title + + c := getClient() + + if err := checkServer(c, widget.settings.apiUrl); err != nil { + return title, err.Error(), widget.settings.wrapText + } + + var sb strings.Builder + + if widget.settings.showSummary { + sb.WriteString(getSummaryView(c, widget.settings)) + } + + if widget.settings.showTopItems > 0 { + sb.WriteString(getTopItemsView(c, widget.settings)) + } + + if widget.settings.showTopClients > 0 { + sb.WriteString(getTopClientsView(c, widget.settings)) + } + + output := sb.String() + + return title, output, widget.settings.wrapText +} + +func (widget *Widget) disable() { + widget.adblockSwitch("disable") +} + +func (widget *Widget) enable() { + widget.adblockSwitch("enable") +}