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/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") +}