.
675 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Pi-CLI
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | Pi-CLI is a command line program used to view data from a Pi-Hole instance directly in your terminal.
13 |
14 |
15 |
16 | 
17 |
18 | # Features
19 |
20 | - Live view
21 | - As shown above, Pi-CLI can generate a live updating view of your Pi-Hole data
22 | - Updates down to a minimum of 1s, providing essentially live query data. Support for smaller intervals may come in the future.
23 | - Update parameters including the number of logged queries in the 'latest queries' table and watch the UI automatically update and pull in the correct data for you. Use your arrow keys to scroll and navigate the table.
24 | - One off commands
25 | - Don't want a live view? Use one of the subcommands of Pi-CLI to tell it exactly what data you want, and it will give it to you. No fancy UI needed.
26 | - Database analysis
27 | - Pi-CLI has the newly added ability to be able to analyse the `pihole-FTL.db` database used a long term data store
28 | for a Pi-Hole. It can extract and analyse all time data, including client, query and ad domain data.
29 | - Quickly configure and forget
30 | - Run one setup command, and Pi-CLI will store and remember all of your details for next time.
31 | - Lightweight
32 | - Even when logging 1000's of DNS queries, memory and CPU usage remains minimal.
33 | - Secure
34 | - Pi-CLI uses cross-platform OS keyring libraries to make sure your Pi-Hole API key is both securely stored and easy to retrieve in the future. Your API key is never stored in plaintext unless you explicitly tell Pi-CLI to not use your keyring.
35 |
36 | # Usage
37 |
38 | ### `~$ picli [global options] command [command options] [arguments...]`
39 |
40 |
41 |
42 | For help, run `~$ picli h`
43 |
44 | For command help, run `~$ picli h`
45 |
46 | For subcommand help, run `~$ picli -h`
47 |
48 |
49 |
50 |
51 | # Commands
52 |
53 | ```
54 | setup, s Configure Pi-CLI
55 | config, c Interact with stored configuration settings
56 | run, r Run a one off command without booting the live view
57 | database, d Analytics options to run on a Pi-Hole's FTL database
58 | help, h Shows a list of commands or help for one command
59 | ```
60 |
61 | ### The `config` command
62 |
63 | _Manage stored config data_
64 |
65 | ```
66 | delete, d Delete stored config data (config file and API key)
67 | view, v View config stored config data (config file and API key)
68 | help, h Shows a list of commands or help for one command
69 | ```
70 |
71 | ### The `run` command
72 |
73 | _Run a single command without the live view_
74 |
75 | ```
76 | summary, s Extract a basic summary of data from the Pi-Hole
77 | top-forwarded, tf Extract the current top 10 forwarded DNS queries
78 | top-blocked, tb Extract the current top 10 blocked DNS queries
79 | latest-queries, lq Extract the latest queries
80 | enable, e Enable the Pi-Hole
81 | disable, d Disable the Pi-Hole
82 | help, h Shows a list of commands or help for one command
83 | ```
84 |
85 | ### The `database` command
86 |
87 | _These commands are ran against a Pi-Hole's FTL database file and provide **all time** data metrics_
88 |
89 | ```
90 | client-summary, cs Summary of all Pi-Hole clients
91 | top-queries, tq Returns the top (all time) queries
92 | help, h Shows a list of commands or help for one command
93 | ```
94 |
95 | # FAQ
96 |
97 | - Where do I get my API key?
98 | - Navigate to your Pi-Hole's web interface, then settings. Click on the API/Web interface tab and press
99 | 'Show API token'.
100 | - Pre-Compiled binaries?
101 | - See [releases](https://github.com/Reeceeboii/Pi-CLI/releases)
102 | - How do I compile myself?
103 |
104 | - With [make](https://www.gnu.org/software/make/)! There is a `Makefile` in the
105 | [cmd/main](https://github.com/Reeceeboii/Pi-CLI/tree/master/cmd/main) directory that can be used for compilation
106 | on Windows, Mac and Linux.
107 |
108 | Compilation targets are: `win`, `mac` and `linux`
109 | ---
110 |
111 | If you find Pi-CLI useful, please consider [donating to the Pi-Hole project](https://pi-hole.net/donate/)
112 |
113 | Or, feel free to submit code to make Pi-CLI even more useful!
114 |
--------------------------------------------------------------------------------
/cmd/main/Makefile:
--------------------------------------------------------------------------------
1 | # REQUIREMENTS
2 | # Make
3 | # go toolchain
4 | # LINUX SPECIFIC
5 | # gcc
6 | # gcc-mingw-w64-x86-64
7 | # WINDOWS SPECIFIC
8 | # MinGW
9 |
10 | # Parameters
11 | GO-CMD=go
12 | GO-BUILD=$(GO-CMD) build
13 |
14 | # Binary names
15 | WINDOWS-BINARY=picli.exe
16 | LINUX-BINARY=picli-linux
17 | MAC-BINARY=picli-mac
18 |
19 | # Linker flags
20 | LINKER_FLAGS=-ldflags "-s -w"
21 |
22 | # Compiling for Windows from Linux requires the MinGW-w64 x86 cross compiler
23 | CGO_ENABLED=CGO_ENABLED=1
24 | MINGW_CROSS_COMPILER=CC=x86_64-w64-mingw32-gcc
25 |
26 | # Cross-compilation compiler operating system flags
27 | GOOS-WINDOWS=GOOS=windows
28 | GOOS-MAC=GOOS=darwin
29 | GOOS-LINUX=GOOS=linux
30 |
31 | # Compiling Pi-CLI for Windows
32 | win: $(objects)
33 | @echo "Compiling for Windows..."
34 | ifeq ($(OS), Windows_NT)
35 | $(GO-BUILD) $(LINKER_FLAGS) -o $(WINDOWS-BINARY)
36 | else
37 | $(GOOS-WINDOWS) $(MINGW_CROSS_COMPILER) $(CGO_ENABLED) $(GO-BUILD) $(LINKER_FLAGS) -o $(WINDOWS-BINARY)
38 | endif
39 |
40 | # Compiling Pi-CLI for Mac
41 | mac:
42 | @echo "Compiling for Mac..."
43 | $(GOOS-MAC) $(GO-BUILD) $(LINKER_FLAGS) -o $(MAC-BINARY)
44 |
45 | # Compiling Pi-CLI for Linux
46 | linux:
47 | @echo "Compiling for Linux..."
48 | $(GOOS-LINUX) $(GO-BUILD) $(LINKER_FLAGS) -o $(LINUX-BINARY)
49 |
--------------------------------------------------------------------------------
/cmd/main/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "github.com/Reeceeboii/Pi-CLI/pkg/cli"
5 | "log"
6 | "os"
7 | )
8 |
9 | func main() {
10 | if err := cli.App.Run(os.Args); err != nil {
11 | log.Fatal(err)
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/Reeceeboii/Pi-CLI
2 |
3 | go 1.18
4 |
5 | require (
6 | github.com/buger/jsonparser v1.1.1
7 | github.com/fatih/color v1.15.0
8 | github.com/gizak/termui/v3 v3.1.0
9 | github.com/mattn/go-sqlite3 v1.14.17
10 | github.com/urfave/cli/v2 v2.25.7
11 | github.com/zalando/go-keyring v0.2.3
12 | golang.org/x/text v0.10.0
13 | )
14 |
15 | require (
16 | github.com/alessio/shellescape v1.4.1 // indirect
17 | github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
18 | github.com/danieljoos/wincred v1.2.0 // indirect
19 | github.com/godbus/dbus/v5 v5.1.0 // indirect
20 | github.com/mattn/go-colorable v0.1.13 // indirect
21 | github.com/mattn/go-isatty v0.0.19 // indirect
22 | github.com/mattn/go-runewidth v0.0.14 // indirect
23 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect
24 | github.com/nsf/termbox-go v1.1.1 // indirect
25 | github.com/rivo/uniseg v0.4.4 // indirect
26 | github.com/russross/blackfriday/v2 v2.1.0 // indirect
27 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
28 | golang.org/x/sys v0.9.0 // indirect
29 | )
30 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0=
2 | github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
3 | github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
4 | github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
5 | github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
6 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
7 | github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE=
8 | github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec=
9 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
10 | github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
11 | github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
12 | github.com/gizak/termui/v3 v3.1.0 h1:ZZmVDgwHl7gR7elfKf1xc4IudXZ5qqfDh4wExk4Iajc=
13 | github.com/gizak/termui/v3 v3.1.0/go.mod h1:bXQEBkJpzxUAKf0+xq9MSWAvWZlE7c+aidmyFlkYTrY=
14 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
15 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
16 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
17 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
18 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
19 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
20 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
21 | github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
22 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
23 | github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
24 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
25 | github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
26 | github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
27 | github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
28 | github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0=
29 | github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
30 | github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ=
31 | github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY=
32 | github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo=
33 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
34 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
35 | github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
36 | github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
37 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
38 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
39 | github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
40 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
41 | github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs=
42 | github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ=
43 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
44 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
45 | github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms=
46 | github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
47 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
48 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
49 | golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
50 | golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
51 | golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58=
52 | golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
53 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
54 |
--------------------------------------------------------------------------------
/img/live-view.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Reeceeboii/Pi-CLI/9efaf8304f897b5e2e68f0d25d1300188a23f0a7/img/live-view.png
--------------------------------------------------------------------------------
/pkg/api/allQueries.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "fmt"
5 | "github.com/Reeceeboii/Pi-CLI/pkg/data"
6 | "github.com/Reeceeboii/Pi-CLI/pkg/network"
7 | "github.com/buger/jsonparser"
8 | "io/ioutil"
9 | "log"
10 | "net/http"
11 | "strconv"
12 | "sync"
13 | "time"
14 | )
15 |
16 | // instance of AllQueries used at runtime
17 | var LiveAllQueries = NewAllQueries()
18 |
19 | const (
20 | AllQueryDataKey = "data"
21 | // The starting setting for the number of queries that are included in the live log
22 | DefaultAmountOfQueries = 10
23 | )
24 |
25 | // Holds information about a single query logged by Pi-Hole
26 | type Query struct {
27 | // UNIX timestamp of when the query was logged
28 | UnixTime string
29 | // The type of query
30 | QueryType string
31 | // The domain the query was sent to
32 | Domain string
33 | // The client that sent the query
34 | OriginClient string
35 | // Where the query was forwarded to
36 | ForwardedTo string
37 | }
38 |
39 | // Holds a slice of query structs
40 | type AllQueries struct {
41 | // Slice of Query structs
42 | Queries []Query
43 | // The amount of queries being stored in the log
44 | AmountOfQueriesInLog int
45 | // The queries stored in a format able to be displayed as a table
46 | Table []string
47 | }
48 |
49 | // Make a new AllQueries instance
50 | func NewAllQueries() *AllQueries {
51 | return &AllQueries{
52 | Queries: make([]Query, DefaultAmountOfQueries),
53 | AmountOfQueriesInLog: DefaultAmountOfQueries,
54 | Table: []string{},
55 | }
56 | }
57 |
58 | // Updates the all queries list with up to date information from the Pi-Hole
59 | func (allQueries *AllQueries) Update(wg *sync.WaitGroup) {
60 | if wg != nil {
61 | wg.Add(1)
62 | defer wg.Done()
63 | }
64 |
65 | queryAmount := strconv.Itoa(allQueries.AmountOfQueriesInLog)
66 | url := data.LivePiCLIData.FormattedAPIAddress +
67 | "?getAllQueries=" +
68 | queryAmount +
69 | "&auth=" +
70 | data.LivePiCLIData.APIKey
71 |
72 | req, err := http.NewRequest("GET", url, nil)
73 | if err != nil {
74 | log.Fatal(err)
75 | }
76 |
77 | res, err := network.HttpClient.Do(req)
78 | if err != nil {
79 | log.Fatal(err)
80 | }
81 | defer res.Body.Close()
82 |
83 | parsedBody, _ := ioutil.ReadAll(res.Body)
84 |
85 | /*
86 | For every index in the parsed body's data array, pull out the required fields.
87 | I tried to use ArrayEach here but couldn't seem to get it to work the way I wanted.
88 | There has to be a nicer way to do this. This approach is absolute garbage.
89 | */
90 | for iter := 0; iter < allQueries.AmountOfQueriesInLog; iter++ {
91 | queryArray, _, _, _ := jsonparser.Get(parsedBody, AllQueryDataKey, fmt.Sprintf("[%d]", iter))
92 | unixTime, _ := jsonparser.GetString(queryArray, "[0]")
93 | queryType, _ := jsonparser.GetString(queryArray, "[1]")
94 | domain, _ := jsonparser.GetString(queryArray, "[2]")
95 | originClient, _ := jsonparser.GetString(queryArray, "[3]")
96 | forwardedTo, _ := jsonparser.GetString(queryArray, "[10]")
97 | allQueries.Queries[iter] = Query{
98 | UnixTime: unixTime,
99 | QueryType: queryType,
100 | Domain: domain,
101 | OriginClient: originClient,
102 | ForwardedTo: forwardedTo,
103 | }
104 | }
105 | allQueries.convertToTable()
106 | }
107 |
108 | // Convert slice of queries to a formatted multidimensional slice
109 | func (allQueries *AllQueries) convertToTable() {
110 | table := make([]string, allQueries.AmountOfQueriesInLog)
111 |
112 | for i, q := range allQueries.Queries {
113 | iTime, _ := strconv.ParseInt(q.UnixTime, 10, 64)
114 | parsedTime := time.Unix(iTime, 0)
115 | entry := fmt.Sprintf("%d [%s] Query type %s from %s to %s forwarded to %s",
116 | (allQueries.AmountOfQueriesInLog)-i,
117 | parsedTime.Format("15:04:05"),
118 | q.QueryType,
119 | q.OriginClient,
120 | q.Domain,
121 | q.ForwardedTo,
122 | )
123 | table[(allQueries.AmountOfQueriesInLog-1)-i] = entry
124 | }
125 | allQueries.Table = table
126 | }
127 |
--------------------------------------------------------------------------------
/pkg/api/enableDisable.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "fmt"
5 | "github.com/Reeceeboii/Pi-CLI/pkg/data"
6 | "github.com/Reeceeboii/Pi-CLI/pkg/network"
7 | "log"
8 | "net/http"
9 | )
10 |
11 | // Enable the Pi-Hole
12 | func EnablePiHole() {
13 | url := data.LivePiCLIData.FormattedAPIAddress + "?enable" + "&auth=" + data.LivePiCLIData.APIKey
14 | req, err := http.NewRequest("GET", url, nil)
15 | if err != nil {
16 | log.Fatal(err)
17 | }
18 |
19 | _, err = network.HttpClient.Do(req)
20 | if err != nil {
21 | log.Fatal(err)
22 | }
23 | }
24 |
25 | // Disable the Pi-Hole
26 | func DisablePiHole(timeout bool, time int64) {
27 | disable := "?disable"
28 | if timeout {
29 | disable += fmt.Sprintf("=%d", time)
30 | }
31 | url := data.LivePiCLIData.FormattedAPIAddress + disable + "&auth=" + data.LivePiCLIData.APIKey
32 | req, err := http.NewRequest("GET", url, nil)
33 | if err != nil {
34 | log.Fatal(err)
35 | }
36 |
37 | _, err = network.HttpClient.Do(req)
38 | if err != nil {
39 | log.Fatal(err)
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/pkg/api/summary.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "github.com/Reeceeboii/Pi-CLI/pkg/network"
5 | "github.com/buger/jsonparser"
6 | "io/ioutil"
7 | "log"
8 | "net/http"
9 | "sync"
10 | )
11 |
12 | var LiveSummary = NewSummary()
13 |
14 | // Keys that can be used to index JSON responses from the Pi-Hole's API
15 | const (
16 | DNSQueriesTodayKey = "dns_queries_today"
17 | AdsBlockedTodayKey = "ads_blocked_today"
18 | PercentBlockedTodayKey = "ads_percentage_today"
19 | DomainsOnBlockListKey = "domains_being_blocked"
20 | StatusKey = "status"
21 | PrivacyLevelKey = "privacy_level"
22 | TotalClientsSeenKey = "clients_ever_seen"
23 | )
24 |
25 | // Summary holds things that do not require authentication to retrieve
26 | type Summary struct {
27 | // Total number of queries logged today
28 | QueriesToday string
29 | // Total number of queries blocked today
30 | BlockedToday string
31 | // Percentage of today's queries that have been blocked
32 | PercentBlockedToday string
33 | // How large is Pi-Hole's active blocklist?
34 | DomainsOnBlocklist string
35 | // Enabled vs. disabled
36 | Status string
37 | // Pi-Hole's current data privacy level
38 | PrivacyLevel string
39 | // Mapping between privacy level numbers and their meanings
40 | PrivacyLevelNumberMapping map[string]string
41 | // The total number of clients that the Pi-Hole has seen
42 | TotalClientsSeen string
43 | }
44 |
45 | /*
46 | Returns a new Summary instance with default values for all fields
47 | */
48 | func NewSummary() *Summary {
49 | return &Summary{
50 | QueriesToday: "",
51 | BlockedToday: "",
52 | PercentBlockedToday: "",
53 | DomainsOnBlocklist: "",
54 | Status: "",
55 | PrivacyLevel: "",
56 | PrivacyLevelNumberMapping: map[string]string{
57 | "0": "Show Everything",
58 | "1": "Hide Domains",
59 | "2": "Hide Domains and Clients",
60 | "3": "Anonymous",
61 | },
62 | TotalClientsSeen: "",
63 | }
64 | }
65 |
66 | // Updates a Summary struct with up to date information
67 | func (summary *Summary) Update(url string, key string, wg *sync.WaitGroup) {
68 | if wg != nil {
69 | wg.Add(1)
70 | defer wg.Done()
71 | }
72 | // create the URL for the summary data and send a request to it
73 | url += "?summary"
74 | if len(key) > 0 {
75 | url += "&auth=" + key
76 | }
77 |
78 | req, err := http.NewRequest("GET", url, nil)
79 | if err != nil {
80 | log.Fatal(err)
81 | }
82 |
83 | res, err := network.HttpClient.Do(req)
84 | if err != nil {
85 | log.Fatal(err)
86 | }
87 | defer res.Body.Close()
88 |
89 | parsedBody, _ := ioutil.ReadAll(res.Body)
90 | // yoink out all the data from the response
91 | // pack it into the struct
92 | summary.QueriesToday, _ = jsonparser.GetString(parsedBody, DNSQueriesTodayKey)
93 | summary.BlockedToday, _ = jsonparser.GetString(parsedBody, AdsBlockedTodayKey)
94 | summary.PercentBlockedToday, _ = jsonparser.GetString(parsedBody, PercentBlockedTodayKey)
95 | summary.DomainsOnBlocklist, _ = jsonparser.GetString(parsedBody, DomainsOnBlockListKey)
96 | summary.Status, _ = jsonparser.GetString(parsedBody, StatusKey)
97 | summary.PrivacyLevel, _ = jsonparser.GetString(parsedBody, PrivacyLevelKey)
98 | summary.TotalClientsSeen, _ = jsonparser.GetString(parsedBody, TotalClientsSeenKey)
99 | }
100 |
--------------------------------------------------------------------------------
/pkg/api/summary_test.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "net/http"
5 | "net/http/httptest"
6 | "strings"
7 | "testing"
8 | )
9 |
10 | const (
11 | // Sample API key for test case usage.
12 | testKey = "c808f484a4e88cc32a9a8bfcce19169c77bcd9c5eec18d859e1bb4b318bf42bf"
13 | )
14 |
15 | // Tests for api.Summary.Update() with an API key
16 | func TestUpdateWithApiKey(t *testing.T) {
17 | summary := NewSummary()
18 | mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19 | // Ensure URL is formatted with the correct query string.
20 | if !strings.Contains(r.URL.RequestURI(), "/api.php?summary&auth="+testKey) {
21 | t.Error("@TestUpdateWithApiKey: api.Summary.Update() did not request the expected Pi Hole api endpoint with expected API key.")
22 | }
23 | }))
24 | defer mockServer.Close()
25 | url := mockServer.URL + "/api.php"
26 |
27 | summary.Update(url, testKey, nil)
28 | }
29 |
30 | // Tests for api.Summary.Update() without an API key
31 | func TestUpdateWithoutAPIKey(t *testing.T) {
32 | summary := NewSummary()
33 | mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
34 | // Ensure URL does not contain &auth as part of its query string.
35 | if strings.Contains(r.URL.RequestURI(), "/api.php?summary&auth=") {
36 | t.Error("@TestUpdateWithoutAPIKey: api.Summary.Update() did not send the expected query string when calling with an API key: /api.php?summary&auth=")
37 | }
38 | // Ensure URL is formatted with the correct query string.
39 | if !strings.Contains(r.URL.RequestURI(), "/api.php?summary") {
40 | t.Error("@TestUpdateWithoutAPIKey: api.Summary.Update() did not send the expected query string when calling with an empty API key: /api.php?summary")
41 | }
42 | }))
43 | defer mockServer.Close()
44 | url := mockServer.URL + "/api.php"
45 | summary.Update(url, "", nil)
46 | }
47 |
--------------------------------------------------------------------------------
/pkg/api/topItems.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "fmt"
5 | "github.com/Reeceeboii/Pi-CLI/pkg/data"
6 | "github.com/Reeceeboii/Pi-CLI/pkg/network"
7 | "github.com/buger/jsonparser"
8 | "io/ioutil"
9 | "log"
10 | "net/http"
11 | "sort"
12 | "strconv"
13 | "sync"
14 | )
15 |
16 | // Instance of TopItems used at runtime
17 | var LiveTopItems = NewTopItems()
18 |
19 | // Keys that can be used to index JSON responses from the Pi-Hole's API
20 | const (
21 | TopQueriesTodayKey = "top_queries"
22 | TopAdsTodayKey = "top_ads"
23 | )
24 |
25 | // TopItems stores top permitted domains and top blocked domains (requires authentication to retrieve)
26 | type TopItems struct {
27 | // Mapping of top DNS queried domains and their occurrences
28 | TopQueries map[string]int
29 | // Mapping of top blocked DNS domains (ads and/or tracking) and their occurrences
30 | TopAds map[string]int
31 | // Pretty list version of TopQueries
32 | PrettyTopQueries []string
33 | // Pretty list version of TopAds
34 | PrettyTopAds []string
35 | }
36 |
37 | // A single domain and the number of times it occurs
38 | type domainOccurrencePair struct {
39 | // The domain
40 | domain string
41 | // The number of times it has occurred
42 | occurrence int
43 | }
44 |
45 | // Create a new TopItems instance
46 | func NewTopItems() *TopItems {
47 | return &TopItems{
48 | TopQueries: map[string]int{},
49 | TopAds: map[string]int{},
50 | PrettyTopQueries: []string{},
51 | PrettyTopAds: []string{},
52 | }
53 | }
54 |
55 | // Updates a TopItems struct with up to date information
56 | func (topItems *TopItems) Update(wg *sync.WaitGroup) {
57 | if wg != nil {
58 | wg.Add(1)
59 | defer wg.Done()
60 | }
61 |
62 | url := data.LivePiCLIData.FormattedAPIAddress + "?topItems" + "&auth=" + data.LivePiCLIData.APIKey
63 | req, err := http.NewRequest("GET", url, nil)
64 | if err != nil {
65 | log.Fatal(err)
66 | }
67 |
68 | res, err := network.HttpClient.Do(req)
69 | if err != nil {
70 | log.Fatal(err)
71 | }
72 | defer res.Body.Close()
73 |
74 | parsedBody, _ := ioutil.ReadAll(res.Body)
75 |
76 | // parse the top queries response
77 | _ = jsonparser.ObjectEach(parsedBody, func(key []byte, value []byte, dataType jsonparser.ValueType, offset int) error {
78 | topItems.TopQueries[string(key)], _ = strconv.Atoi(string(value))
79 | return nil
80 | }, TopQueriesTodayKey)
81 |
82 | // and the same for the top ad networks
83 | _ = jsonparser.ObjectEach(parsedBody, func(key []byte, value []byte, dataType jsonparser.ValueType, offset int) error {
84 | topItems.TopAds[string(key)], _ = strconv.Atoi(string(value))
85 | return nil
86 | }, TopAdsTodayKey)
87 |
88 | topItems.prettyConvert()
89 | }
90 |
91 | // Convert maps of domain:hits to nice lists that can be displayed
92 | func (topItems *TopItems) prettyConvert() {
93 | var sortedTopQueries []domainOccurrencePair
94 | var sortedTopAds []domainOccurrencePair
95 | topItems.PrettyTopQueries = []string{}
96 | topItems.PrettyTopAds = []string{}
97 |
98 | for key, value := range topItems.TopQueries {
99 | sortedTopQueries = append(sortedTopQueries, domainOccurrencePair{
100 | domain: key,
101 | occurrence: value,
102 | })
103 | }
104 |
105 | for key, value := range topItems.TopAds {
106 | sortedTopAds = append(sortedTopAds, domainOccurrencePair{
107 | domain: key,
108 | occurrence: value,
109 | })
110 | }
111 |
112 | // sort ads and domains by occurrence
113 | sort.SliceStable(sortedTopQueries[:], func(i, j int) bool {
114 | return sortedTopQueries[i].occurrence > sortedTopQueries[j].occurrence
115 | })
116 | sort.SliceStable(sortedTopAds[:], func(i, j int) bool {
117 | return sortedTopAds[i].occurrence > sortedTopAds[j].occurrence
118 | })
119 |
120 | for _, domain := range sortedTopQueries {
121 | listEntry := fmt.Sprintf("%d hits | %s", domain.occurrence, domain.domain)
122 | topItems.PrettyTopQueries = append(topItems.PrettyTopQueries, listEntry)
123 | }
124 | for _, domain := range sortedTopAds {
125 | listEntry := fmt.Sprintf("%d hits | %s", domain.occurrence, domain.domain)
126 | topItems.PrettyTopAds = append(topItems.PrettyTopAds, listEntry)
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/pkg/auth/auth.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "io/ioutil"
5 | "log"
6 | "net/http"
7 |
8 | "github.com/Reeceeboii/Pi-CLI/pkg/network"
9 | "github.com/buger/jsonparser"
10 | "github.com/zalando/go-keyring"
11 | )
12 |
13 | // Keyring Service: Required for use in authentication and API key management
14 | var KeyringService = "PiCLI"
15 |
16 | // Keyring User: Required for use in authentication and API key management
17 | var KeyringUsr = "api-key"
18 |
19 | // Retrieve the API key from the system keyring
20 | func RetrieveAPIKeyFromKeyring() string {
21 | APIKey, err := keyring.Get(KeyringService, KeyringUsr)
22 | if err != nil {
23 | log.Fatal(err)
24 | }
25 | return APIKey
26 | }
27 |
28 | /*
29 | Store the API key in the system keyring. Returns an error if this action failed.
30 | */
31 | func StoreAPIKeyInKeyring(key string) error {
32 | if err := keyring.Set(KeyringService, KeyringUsr, key); err != nil {
33 | return err
34 | }
35 | return nil
36 | }
37 |
38 | // Delete the stored API key if it exists
39 | func DeleteAPIKeyFromKeyring() bool {
40 | if err := keyring.Delete(KeyringService, KeyringUsr); err != nil {
41 | return false
42 | }
43 | return true
44 | }
45 |
46 | // Is there an entry for the API key in the system keyring?
47 | func APIKeyIsInKeyring() bool {
48 | if _, err := keyring.Get(KeyringService, KeyringUsr); err != nil {
49 | return false
50 | }
51 | return true
52 | }
53 |
54 | // Does an key allow authentication? I.e., is is valid?
55 | func ValidateAPIKey(url string, key string) bool {
56 | /*
57 | To test the validity of the API key, we can attempt to enable the Pi-Hole.
58 |
59 | The response for a correct key:
60 | {
61 | "status": "enabled"
62 | }
63 |
64 | And the response for an incorrect key:
65 | []
66 |
67 | Therefore we can simply perform a lookup for that "status" key. If it's there, the key is valid.
68 |
69 | */
70 |
71 | queryString := url + "?enable" + "&auth=" + key
72 | req, err := http.NewRequest("GET", queryString, nil)
73 | if err != nil {
74 | log.Fatal(err)
75 | }
76 |
77 | res, err := network.HttpClient.Do(req)
78 | if err != nil {
79 | log.Fatal(err)
80 | }
81 | defer res.Body.Close()
82 | parsedBody, _ := ioutil.ReadAll(res.Body)
83 |
84 | if _, err := jsonparser.GetString(parsedBody, "status"); err != nil {
85 | return false
86 | }
87 | return true
88 | }
89 |
--------------------------------------------------------------------------------
/pkg/auth/auth_test.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 | "net/http/httptest"
7 | "strings"
8 | "testing"
9 | )
10 |
11 | const (
12 | // Sample API key for test case usage.
13 | testKey = "c808f484a4e88cc32a9a8bfcce19169c77bcd9c5eec18d859e1bb4b318bf42bf"
14 | )
15 |
16 | // Calling init() in order to overwrite global variables for test purposes.
17 | func init() {
18 | KeyringService = "test-service" // Overwrite KeyringService for test cases
19 | KeyringUsr = "test-key" // Overwrite KeyringUsr for test cases
20 | }
21 |
22 | /*
23 | NOTE:
24 | Each test case is self-contained, for example: A key is stored at the beginning of each case and deleted before it ends.
25 | We do this because we cannot rely on Go to run its tests sequentially every time.
26 | */
27 |
28 | // Tests for auth.APIKeyIsInKeyring()
29 | func TestAPIKeyIsInKeyring(t *testing.T) {
30 | // Ensuring StoreAPIKeyInKeyring() can successfully store a key in the keyring.
31 | err := StoreAPIKeyInKeyring(testKey)
32 | if err != nil {
33 | t.Errorf("@TestAPIKeyIsInKeyring: auth.StoreAPIKeyInKeyring() failed to store API key: %s", err)
34 | }
35 |
36 | // Ensuring APIKeyIsInKeyring() can successfully find the stored key.
37 | if !APIKeyIsInKeyring() {
38 | t.Error("@TestAPIKeyIsInKeyring: auth.APIKeyIsInKeyring() failed to find key in keyring.")
39 | }
40 |
41 | // Ensuring DeleteAPIKeyFromKeyring() is able to successfully find and delete key
42 | if !DeleteAPIKeyFromKeyring() {
43 | t.Error("@TestRetrieveAPIKeyFromKeyring: auth.DeleteAPIKeyFromKeyring() did not find/delete key in keyring.")
44 | }
45 |
46 | // Ensuring APIKeyIsInKeyring() cannot find a key that should not exist.
47 | if APIKeyIsInKeyring() {
48 | t.Error("@TestAPIKeyIsInKeyring: auth.APIKeyIsInKeyring() found key in keyring after it should have been deleted.")
49 | }
50 | }
51 |
52 | // Tests for auth.RetrieveAPIKeyFromKeyring()
53 | func TestRetrieveAPIKeyFromKeyring(t *testing.T) {
54 | // Ensuring StoreAPIKeyInKeyring() can successfully store a key in the keyring.
55 | err := StoreAPIKeyInKeyring(testKey)
56 | if err != nil {
57 | t.Errorf("@TestRetrieveAPIKeyFromKeyring: auth.StoreAPIKeyInKeyring() failed to store API key: %s", err)
58 | }
59 |
60 | // Ensuring RetrieveAPIKeyFromKeyring() can successfully find the right key in keyring.
61 | key := RetrieveAPIKeyFromKeyring()
62 | if key != testKey {
63 | t.Error("@TestRetrieveAPIKeyFromKeyring: auth.RetrieveAPIKeyFromKeyring() did not match provided test key.")
64 | }
65 |
66 | // Ensuring DeleteAPIKeyFromKeyring() is able to successfully find and delete key
67 | if !DeleteAPIKeyFromKeyring() {
68 | t.Error("@TestRetrieveAPIKeyFromKeyring: auth.DeleteAPIKeyFromKeyring() did not find/delete key in keyring.")
69 | }
70 | }
71 |
72 | // Tests for auth.DeleteAPIKeyFromKeyring()
73 | func TestDeleteAPIKeyFromKeyring(t *testing.T) {
74 | // Ensuring StoreAPIKeyInKeyring() can successfully store a key in the keyring.
75 | err := StoreAPIKeyInKeyring(testKey)
76 | if err != nil {
77 | t.Errorf("@TestDeleteAPIKeyFromKeyring: auth.StoreAPIKeyInKeyring() failed to store API key: %s", err)
78 | }
79 |
80 | // Ensuring DeleteAPIKeyFromKeyring() is able to successfully find and delete key
81 | if !DeleteAPIKeyFromKeyring() {
82 | t.Error("@TestDeleteAPIKeyFromKeyring: auth.DeleteAPIKeyFromKeyring() did not find/delete key in keyring.")
83 | }
84 |
85 | // Ensuring DeleteAPIKeyFromKeyring() does not find or delete a key as expected when the key does not exist.
86 | if DeleteAPIKeyFromKeyring() {
87 | t.Error("@TestDeleteAPIKeyFromKeyring: auth.DeleteAPIKeyFromKeyring() found/deleted key from keyring when one should not exist, it should have been deleted in the previous assertion.")
88 | }
89 | }
90 |
91 | // Tests for auth.TestValidateAPIKey()
92 | func TestValidateAPIKey(t *testing.T) {
93 | mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
94 | // Ensure URL is formatted with the correct query string.
95 | if !strings.Contains(r.URL.RequestURI(), "/api.php?enable&auth=") {
96 | fmt.Println(r.URL.RequestURI())
97 | t.Error("@TestValidateAPIKey: auth.ValidateAPIKey() did not request the expected Pi Hole auth endpoint.")
98 | }
99 | if r.URL.Query().Get("auth") != testKey {
100 | w.Write([]byte(`{}`))
101 | return
102 | }
103 | w.Write([]byte(`{"status": "enabled"}`))
104 | }))
105 |
106 | defer mockServer.Close()
107 | url := mockServer.URL + "/api.php"
108 | // Requests should succeed with the correct API key
109 | if !ValidateAPIKey(url, testKey) {
110 | t.Error("@TestValidateAPIKey: auth.ValidateAPIKey() should have received a successful response from the server, but it did not.")
111 | }
112 |
113 | // Request should return an empty response with the wrong API key
114 | if ValidateAPIKey(url, "test") {
115 | t.Error("@TestValidateAPIKey: auth.ValidateAPIKey() should have received an empty response from the server as it is looking for the wrong API key.")
116 | }
117 | }
118 |
--------------------------------------------------------------------------------
/pkg/cli/cli.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "fmt"
5 | "github.com/Reeceeboii/Pi-CLI/pkg/database"
6 | "github.com/Reeceeboii/Pi-CLI/pkg/ui"
7 | "github.com/urfave/cli/v2"
8 | "time"
9 | )
10 |
11 | /*
12 | This is the main CLI app, it contains all the various commands and subcommands
13 | that Pi-CLI is capable of responding to, and manages all of their corresponding flags
14 | */
15 | var App = cli.App{
16 | Name: "Pi-CLI",
17 | Usage: "Third party program to retrieve and display Pi-Hole data right from your terminal",
18 | Description: "Pi-Hole data right from your terminal. Live updating view, query history extraction and more!",
19 | Copyright: fmt.Sprintf("Copyright (c) %d Reece Mercer", time.Now().Year()),
20 | Authors: []*cli.Author{
21 | {
22 | Name: "Reece Mercer",
23 | Email: "reecemercer981@gmail.com",
24 | },
25 | },
26 | Commands: []*cli.Command{
27 | {
28 | Name: "setup",
29 | Aliases: []string{"s"},
30 | Usage: "Configure Pi-CLI",
31 | Action: SetupCommand,
32 | },
33 | {
34 | Name: "config",
35 | Aliases: []string{"c"},
36 | Usage: "Interact with stored configuration settings",
37 | Subcommands: []*cli.Command{
38 | {
39 | Name: "delete",
40 | Aliases: []string{"d"},
41 | Usage: "Delete stored config data (config file and API key)",
42 | Action: ConfigDeleteCommand,
43 | },
44 | {
45 | Name: "view",
46 | Aliases: []string{"v"},
47 | Usage: "View config stored config data (config file and API key)",
48 | Action: ConfigViewCommand,
49 | },
50 | },
51 | },
52 | {
53 | Name: "run",
54 | Aliases: []string{"r"},
55 | Usage: "Run a one off command without booting the live view",
56 | Subcommands: []*cli.Command{
57 | {
58 | Name: "summary",
59 | Aliases: []string{"s"},
60 | Usage: "Extract a basic summary of data from the Pi-Hole",
61 | Action: RunSummaryCommand,
62 | },
63 | {
64 | Name: "top-forwarded",
65 | Aliases: []string{"tf"},
66 | Usage: "Extract the current top 10 forwarded DNS queries",
67 | Action: RunTopTenForwardedCommand,
68 | },
69 | {
70 | Name: "top-blocked",
71 | Aliases: []string{"tb"},
72 | Usage: "Extract the current top 10 blocked DNS queries",
73 | Action: RunTopTenBlockedCommand,
74 | },
75 | {
76 | Name: "latest-queries",
77 | Aliases: []string{"lq"},
78 | Usage: "Extract the latest queries",
79 | Flags: []cli.Flag{
80 | &cli.Int64Flag{
81 | Name: "limit",
82 | Aliases: []string{"l"},
83 | Usage: "The limit on the number of queries to extract",
84 | DefaultText: "10",
85 | },
86 | },
87 | Action: RunLatestQueriesCommand,
88 | },
89 | {
90 | Name: "enable",
91 | Aliases: []string{"e"},
92 | Usage: "Enable the Pi-Hole",
93 | Action: RunEnablePiHoleCommand,
94 | },
95 | {
96 | Name: "disable",
97 | Aliases: []string{"d"},
98 | Usage: "Disable the Pi-Hole",
99 | Flags: []cli.Flag{
100 | &cli.Int64Flag{
101 | Name: "timeout",
102 | Aliases: []string{"t"},
103 | Usage: "A timeout in seconds. Pi-Hole will re-enable after this time has elapsed.",
104 | DefaultText: "permanent",
105 | },
106 | },
107 | Action: RunDisablePiHoleCommand,
108 | },
109 | },
110 | },
111 | {
112 | Name: "database",
113 | Aliases: []string{"d"},
114 | Usage: "Analytics options to run on a Pi-Hole's FTL database",
115 | Subcommands: []*cli.Command{
116 | {
117 | Name: "client-summary",
118 | Aliases: []string{"cs"},
119 | Usage: "Summary of all Pi-Hole clients",
120 | Flags: []cli.Flag{
121 | &cli.StringFlag{
122 | Name: "path",
123 | Aliases: []string{"p"},
124 | Usage: "Path to a Pi-Hole FTL database file",
125 | DefaultText: database.DefaultDatabaseFileLocation,
126 | },
127 | },
128 | Action: RunDatabaseClientSummaryCommand,
129 | },
130 | {
131 | Name: "top-queries",
132 | Aliases: []string{"tq"},
133 | Usage: "Returns the top (all time) queries",
134 | Flags: []cli.Flag{
135 | &cli.StringFlag{
136 | Name: "path",
137 | Aliases: []string{"p"},
138 | Usage: "Path to the Pi-Hole FTL database file",
139 | DefaultText: database.DefaultDatabaseFileLocation,
140 | },
141 | &cli.Int64Flag{
142 | Name: "limit",
143 | Aliases: []string{"l"},
144 | Usage: "The limit on the number of queries to extract",
145 | DefaultText: "10",
146 | },
147 | &cli.StringFlag{
148 | Name: "filter",
149 | Aliases: []string{"f"},
150 | Usage: "Filter by domain or word. (e.g. 'google.com', 'spotify', 'facebook' etc...)",
151 | DefaultText: "No filter",
152 | },
153 | },
154 | Action: RunDatabaseTopQueriesCommand,
155 | },
156 | },
157 | },
158 | },
159 |
160 | Action: func(context *cli.Context) error {
161 | InitialisePICLI()
162 | ui.StartUI()
163 | return nil
164 | },
165 | }
166 |
--------------------------------------------------------------------------------
/pkg/cli/configCmd.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "fmt"
5 | "github.com/Reeceeboii/Pi-CLI/pkg/auth"
6 | "github.com/Reeceeboii/Pi-CLI/pkg/data"
7 | "github.com/fatih/color"
8 | "github.com/urfave/cli/v2"
9 | "os/exec"
10 | )
11 |
12 | /*
13 | Searches for and deletes:
14 | - the API key from the system keyring (if exists)
15 | - the config file from the user's home directory (if exists)
16 | */
17 | func ConfigDeleteCommand(*cli.Context) error {
18 | if auth.DeleteAPIKeyFromKeyring() {
19 | color.Green("System keyring API entry has been deleted!")
20 | } else {
21 | color.Yellow("Pi-CLI did not find a keyring entry to delete")
22 | }
23 | if data.DeleteConfigFile() {
24 | color.Green("Stored config file has been deleted!")
25 | } else {
26 | color.Yellow("Pi-CLI did not find a config file to delete")
27 | }
28 | return nil
29 | }
30 |
31 | /*
32 | Displays any saved configuration data to the user.
33 | If a config file is present, that can be loaded and displayed,
34 | otherwise, the user can be prompted to create one.
35 | */
36 | func ConfigViewCommand(*cli.Context) error {
37 | /*
38 | - Pi-Hole IP address
39 | - Pi-Hole port
40 | - Data refresh rate
41 | */
42 | if data.ConfigFileExists() {
43 | // Display the location of the config file in the filesystem
44 | color.Green("Config location: %s\n", data.GetConfigFileLocation())
45 |
46 | // Open the config file so we can extract data from it
47 | data.PICLISettings.LoadFromFile()
48 | fmt.Printf("Pi-Hole address: %s\n", data.PICLISettings.PiHoleAddress)
49 | fmt.Printf("Pi-Hole port: %d\n", data.PICLISettings.PiHolePort)
50 | fmt.Printf("Refresh rate: %ds\n", data.PICLISettings.RefreshS)
51 | } else {
52 | color.Yellow("No config file is present - run the setup command to create one")
53 | }
54 |
55 | // and the same with the API key
56 | if auth.APIKeyIsInKeyring() {
57 | fmt.Printf("API key (keyring): %s\n", auth.RetrieveAPIKeyFromKeyring())
58 | } else if data.PICLISettings.APIKeyIsInFile() {
59 | fmt.Printf("API key (config file): %s\n", data.PICLISettings.APIKey)
60 | } else {
61 | color.Yellow("No API key has been provided - run the setup command to enter it")
62 | }
63 |
64 | _ = exec.Command(data.GetConfigFileLocation()).Run()
65 |
66 | return nil
67 | }
68 |
--------------------------------------------------------------------------------
/pkg/cli/databaseCmd.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "github.com/Reeceeboii/Pi-CLI/pkg/database"
5 | "github.com/urfave/cli/v2"
6 | )
7 |
8 | /*
9 | FOR ALL DATABASE COMMANDS:
10 | If no path is provided by the user, Pi-CLI will assume that the database file's
11 | name hasn't been changed from it's default name, and that is has been placed in the
12 | same working directory that it is being executed from. This saves some command typing.
13 | */
14 |
15 | /*
16 | Extracts a summary of data regarding the Pi-Hole's clients
17 | */
18 | func RunDatabaseClientSummaryCommand(c *cli.Context) error {
19 | path := c.String("path")
20 | if path == "" {
21 | path = database.DefaultDatabaseFileLocation
22 | }
23 |
24 | conn := database.Connect(path)
25 | database.ClientSummary(conn)
26 |
27 | return nil
28 | }
29 |
30 | /*
31 | Extracts all time top query data from the database file.
32 | */
33 | func RunDatabaseTopQueriesCommand(c *cli.Context) error {
34 | path := c.String("path")
35 | if path == "" {
36 | path = database.DefaultDatabaseFileLocation
37 | }
38 |
39 | conn := database.Connect(path)
40 | database.TopQueries(conn, c.Int64("limit"), c.String("filter"))
41 |
42 | return nil
43 | }
44 |
--------------------------------------------------------------------------------
/pkg/cli/enableDisableCmd.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "github.com/Reeceeboii/Pi-CLI/pkg/api"
5 | "github.com/Reeceeboii/Pi-CLI/pkg/data"
6 | "github.com/fatih/color"
7 | "github.com/urfave/cli/v2"
8 | )
9 |
10 | /*
11 | Enable the Pi-Hole if it is not already enabled,
12 | */
13 | func RunEnablePiHoleCommand(*cli.Context) error {
14 | InitialisePICLI()
15 | api.LiveSummary.Update(data.LivePiCLIData.FormattedAPIAddress, data.LivePiCLIData.APIKey, nil)
16 |
17 | if api.LiveSummary.Status == "enabled" {
18 | color.Yellow("Pi-Hole is already enabled!")
19 | } else {
20 | api.EnablePiHole()
21 | color.Green("Pi-Hole enabled")
22 | }
23 |
24 | return nil
25 | }
26 |
27 | /*
28 | Disable the Pi-Hole. This command also takes an optional timeout parameter in seconds.
29 | If given and within constraints, the Pi-Hole will automatically re-enable after this
30 | time period has elapsed
31 | */
32 | func RunDisablePiHoleCommand(c *cli.Context) error {
33 | InitialisePICLI()
34 | api.LiveSummary.Update(data.LivePiCLIData.FormattedAPIAddress, data.LivePiCLIData.APIKey, nil)
35 |
36 | if api.LiveSummary.Status == "disabled" {
37 | color.Yellow("Pi-Hole is already disabled!")
38 | } else {
39 | timeout := c.Int64("timeout")
40 | if timeout == 0 {
41 | api.DisablePiHole(false, 0)
42 | color.Green("Pi-Hole disabled until explicitly re-enabled")
43 | } else {
44 | api.DisablePiHole(true, timeout)
45 | color.Green("Pi-Hole disabled. Will re-enable in %d seconds\n", timeout)
46 | }
47 | }
48 |
49 | return nil
50 | }
51 |
--------------------------------------------------------------------------------
/pkg/cli/init.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "github.com/Reeceeboii/Pi-CLI/pkg/auth"
5 | "github.com/Reeceeboii/Pi-CLI/pkg/data"
6 | "github.com/Reeceeboii/Pi-CLI/pkg/network"
7 | "github.com/fatih/color"
8 | "os"
9 | )
10 |
11 | /*
12 | Validate that the config file and API key are in place.
13 | Load the required settings into memory
14 | */
15 | func InitialisePICLI() {
16 | // firstly, has a config file been created?
17 | if !data.ConfigFileExists() {
18 | color.Red("Please configure Pi-CLI via the 'setup' command")
19 | os.Exit(1)
20 | }
21 |
22 | data.PICLISettings.LoadFromFile()
23 |
24 | // retrieve the API key depending upon its storage location
25 | if !data.PICLISettings.APIKeyIsInFile() && !auth.APIKeyIsInKeyring() {
26 | color.Red("Please configure Pi-CLI via the 'setup' command")
27 | os.Exit(1)
28 | } else {
29 | if data.PICLISettings.APIKeyIsInFile() {
30 | data.LivePiCLIData.APIKey = data.PICLISettings.APIKey
31 | } else {
32 | data.LivePiCLIData.APIKey = auth.RetrieveAPIKeyFromKeyring()
33 | }
34 | }
35 |
36 | data.LivePiCLIData.Settings = data.PICLISettings
37 | data.LivePiCLIData.FormattedAPIAddress = network.GenerateAPIAddress(
38 | data.PICLISettings.PiHoleAddress,
39 | data.PICLISettings.PiHolePort)
40 | }
41 |
--------------------------------------------------------------------------------
/pkg/cli/runCmd.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "fmt"
5 | "strings"
6 | "time"
7 |
8 | "github.com/Reeceeboii/Pi-CLI/pkg/api"
9 | "github.com/Reeceeboii/Pi-CLI/pkg/data"
10 | "github.com/fatih/color"
11 | "github.com/urfave/cli/v2"
12 | )
13 |
14 | /*
15 | This file stores commands that can be ran 'one-off', i.e. without needing to boot the live UI
16 | */
17 |
18 | /*
19 | Extracts a quick summary of the previous 24/hr of data from the Pi-Hole.
20 | */
21 | func RunSummaryCommand(*cli.Context) error {
22 | InitialisePICLI()
23 | api.LiveSummary.Update(data.LivePiCLIData.FormattedAPIAddress, data.LivePiCLIData.APIKey, nil)
24 | fmt.Printf("Summary @ %s\n", time.Now().Format(time.Stamp))
25 | fmt.Println()
26 |
27 | if api.LiveSummary.Status == "enabled" {
28 | fmt.Printf("Pi-Hole status: %s\n", color.GreenString(strings.Title(api.LiveSummary.Status)))
29 | } else {
30 | fmt.Printf("Pi-Hole status: %s\n", color.RedString(strings.Title(api.LiveSummary.Status)))
31 | }
32 |
33 | fmt.Println()
34 | fmt.Printf("Queries /24hr: %s\n", api.LiveSummary.QueriesToday)
35 | fmt.Printf("Blocked /24hr: %s\n", api.LiveSummary.BlockedToday)
36 | fmt.Printf("Percent blocked: %s%%\n", api.LiveSummary.PercentBlockedToday)
37 | fmt.Printf("Domains on blocklist: %s\n", api.LiveSummary.DomainsOnBlocklist)
38 | fmt.Printf("Privacy level: %s - %s\n",
39 | api.LiveSummary.PrivacyLevel,
40 | api.LiveSummary.PrivacyLevelNumberMapping[api.LiveSummary.PrivacyLevel],
41 | )
42 | fmt.Printf("Total clients seen: %s\n", api.LiveSummary.TotalClientsSeen)
43 | fmt.Println()
44 | return nil
45 | }
46 |
47 | /*
48 | Extract the current top 10 permitted domains that have been forwarded to the upstream DNS resolver
49 | */
50 | func RunTopTenForwardedCommand(*cli.Context) error {
51 | InitialisePICLI()
52 |
53 | api.LiveTopItems.Update(nil)
54 | fmt.Printf("Top queries as of @ %s\n\n", time.Now().Format(time.Stamp))
55 | for _, q := range api.LiveTopItems.PrettyTopQueries {
56 | fmt.Println(q)
57 | }
58 |
59 | return nil
60 | }
61 |
62 | /*
63 | Extract the current top 10 blocked domains that the FTL has filtered out and not forwarded
64 | to the upstream DNS resolver
65 | */
66 | func RunTopTenBlockedCommand(*cli.Context) error {
67 | InitialisePICLI()
68 |
69 | api.LiveTopItems.Update(nil)
70 | fmt.Printf("Top blocked domains as of @ %s\n\n", time.Now().Format(time.Stamp))
71 | for _, q := range api.LiveTopItems.PrettyTopAds {
72 | fmt.Println(q)
73 | }
74 |
75 | return nil
76 | }
77 |
78 | func RunLatestQueriesCommand(c *cli.Context) error {
79 | queryAmount := c.Int("limit")
80 | if queryAmount == 0 {
81 | queryAmount = 10
82 | }
83 |
84 | if queryAmount < 1 {
85 | fmt.Println("Please enter a number of queries >= 1")
86 | return nil
87 | }
88 |
89 | InitialisePICLI()
90 |
91 | api.LiveAllQueries.AmountOfQueriesInLog = queryAmount
92 | api.LiveAllQueries.Queries = make([]api.Query, api.LiveAllQueries.AmountOfQueriesInLog)
93 | api.LiveAllQueries.Update(nil)
94 |
95 | for _, query := range api.LiveAllQueries.Table {
96 | fmt.Println(query)
97 | }
98 |
99 | return nil
100 | }
101 |
--------------------------------------------------------------------------------
/pkg/cli/setupCmd.go:
--------------------------------------------------------------------------------
1 | package cli
2 |
3 | import (
4 | "bufio"
5 | "fmt"
6 | "github.com/Reeceeboii/Pi-CLI/pkg/auth"
7 | "github.com/Reeceeboii/Pi-CLI/pkg/data"
8 | "github.com/Reeceeboii/Pi-CLI/pkg/network"
9 | "github.com/fatih/color"
10 | "github.com/urfave/cli/v2"
11 | "log"
12 | "net"
13 | "net/http"
14 | "os"
15 | "strconv"
16 | "strings"
17 | )
18 |
19 | /*
20 | Reads in data from the user and uses it to construct a config file that Pi-CLI can use
21 | in the future.
22 |
23 | The setup commands takes:
24 | - The IP address of the target Pi-Hole instance
25 | - The port exposing the Pi-Hole's web interface
26 | - A data refresh rate in seconds
27 | - User's Pi-Hole API key (used for authentication)
28 |
29 | It will then ask them if they wish to store the API key in their system keyring or the config
30 | file itself.
31 | */
32 | func SetupCommand(c *cli.Context) error {
33 | reader := bufio.NewReader(os.Stdin)
34 |
35 | for {
36 | // read in the IP address and check that it is valid
37 | fmt.Print(" > Please enter the IP address of your Pi-Hole: ")
38 | piHoleAddress, _ := reader.ReadString('\n')
39 | ip := net.ParseIP(strings.TrimSpace(piHoleAddress))
40 | if ip == nil {
41 | color.Yellow("Please enter a valid IP address")
42 | continue
43 | }
44 | data.PICLISettings.PiHoleAddress = ip.String()
45 | break
46 | }
47 |
48 | for {
49 | // read in the port
50 | fmt.Print(" > Please enter the port that exposes the web interface (default 80): ")
51 | piHolePort, _ := reader.ReadString('\n')
52 | trimmed := strings.TrimSpace(piHolePort)
53 | // if the user entered something, validate and apply. Else, revert to the default
54 | if len(trimmed) > 0 {
55 | intPiHolePort, err := strconv.Atoi(trimmed)
56 | if err != nil {
57 | color.Yellow("Please enter a number")
58 | continue
59 | }
60 | if intPiHolePort < 1 || intPiHolePort > 65535 {
61 | color.Yellow("Port must be between 1 and 65535")
62 | continue
63 | }
64 | testAddressWithPort := network.GenerateAPIAddress(data.PICLISettings.PiHoleAddress, intPiHolePort)
65 | if network.IsAlive(testAddressWithPort) {
66 | data.PICLISettings.PiHolePort = intPiHolePort
67 | } else {
68 | continue
69 | }
70 | } else {
71 | testAddressWithPort := network.GenerateAPIAddress(data.PICLISettings.PiHoleAddress, data.DefaultPort)
72 | if network.IsAlive(testAddressWithPort) {
73 | data.PICLISettings.PiHolePort = data.DefaultPort
74 | } else {
75 | continue
76 | }
77 | }
78 |
79 | // send a request to the PiHole to validate that the IP and port actually point to it
80 | tempURL := fmt.Sprintf(
81 | "http://%s:%d/admin/api.php",
82 | data.PICLISettings.PiHoleAddress,
83 | data.PICLISettings.PiHolePort)
84 | req, err := http.NewRequest("GET", tempURL, nil)
85 | if err != nil {
86 | log.Fatal(err)
87 | }
88 | res, err := network.HttpClient.Do(req)
89 |
90 | // if the details are valid and the request didn't time out...
91 | // lazy evaluation saves us from deref errors here and saves a check
92 | if err == nil && network.ValidatePiHoleDetails(res) {
93 | break
94 | } else {
95 | color.Yellow("Pi-Hole doesn't seem to be alive, check your details and try again!")
96 | fmt.Println()
97 | }
98 | }
99 |
100 | color.Green(
101 | "Pi-Hole reachable at %s:%d!\n",
102 | data.PICLISettings.PiHoleAddress,
103 | data.PICLISettings.PiHolePort)
104 |
105 | // read in the data refresh rate
106 | for {
107 | fmt.Print(" > Please enter your preferred data refresh rate in seconds (default 1s): ")
108 | refreshS, _ := reader.ReadString('\n')
109 | trimmed := strings.TrimSpace(refreshS)
110 | if len(trimmed) > 0 {
111 | intRefreshS, err := strconv.Atoi(trimmed)
112 | if err != nil {
113 | color.Yellow("Please enter a number")
114 | continue
115 | }
116 | if intRefreshS < 1 {
117 | color.Yellow("Refresh time cannot be less than 1 second")
118 | continue
119 | }
120 | data.PICLISettings.RefreshS = intRefreshS
121 | break
122 | } else {
123 | break
124 | }
125 | }
126 |
127 | // read in the API key and work out where the user wants to store it (keyring or config file)
128 | for {
129 | fmt.Print(" > Please enter your Pi-Hole API key: ")
130 | apiKey, _ := reader.ReadString('\n')
131 | apiKey = strings.TrimSpace(apiKey)
132 | if len(apiKey) < 1 {
133 | color.Yellow("Please provide your API key for authentication")
134 | continue
135 | }
136 |
137 | data.PICLISettings.APIKey = apiKey
138 |
139 | // before we store the API token (keyring or config file), we should check that it's valid
140 | // the address + port have been validated by this point so we're safe to shoot requests at it
141 | data.LivePiCLIData.Settings = data.PICLISettings
142 | data.LivePiCLIData.FormattedAPIAddress = network.GenerateAPIAddress(
143 | data.PICLISettings.PiHoleAddress,
144 | data.PICLISettings.PiHolePort)
145 |
146 | if !auth.ValidateAPIKey(data.LivePiCLIData.FormattedAPIAddress, data.PICLISettings.APIKey) {
147 | color.Yellow("That API token doesn't seem to be correct, check it and try again!")
148 | } else {
149 | break
150 | }
151 | }
152 |
153 | color.Green("Authenticated with API key!\n")
154 |
155 | fmt.Print(" > Do you wish to store the API key in your system keyring? (y/n - default y): ")
156 | storageChoice, _ := reader.ReadString('\n')
157 | storageChoice = strings.ToLower(strings.TrimSpace(storageChoice))
158 |
159 | // if they wish to use their system's keyring...
160 | if storageChoice == "y" || len(storageChoice) == 0 {
161 | err := auth.StoreAPIKeyInKeyring(data.PICLISettings.APIKey)
162 |
163 | if err == nil {
164 | color.Green("Your API token has been securely stored in your system keyring")
165 | /*
166 | After the API key has been saved to the keyring, there is no longer a need to save it
167 | to the config file, so the stored copy of it can be removed from the in-memory settings
168 | instance before it gets serialised to disk
169 | */
170 | data.PICLISettings.APIKey = ""
171 | } else {
172 | color.Yellow("System keyring call failed, falling back to config file")
173 | }
174 | }
175 |
176 | // write config file to disk
177 | // all fields in the settings struct would have been set by this point
178 | if err := data.PICLISettings.SaveToFile(); err != nil {
179 | color.Red("Failed to save settings")
180 | log.Fatal(err.Error())
181 | }
182 |
183 | color.Green("\nConfiguration successfully saved to %s", data.GetConfigFileLocation())
184 | return nil
185 | }
186 |
--------------------------------------------------------------------------------
/pkg/data/data.go:
--------------------------------------------------------------------------------
1 | package data
2 |
3 | import (
4 | "time"
5 | )
6 |
7 | // live updating config data used at runtime
8 | var LivePiCLIData = NewPiCLIData()
9 |
10 | // Stores the data needed by Pi-CLI during runtime
11 | type PiCLIData struct {
12 | // An instance of settings.Settings
13 | Settings *Settings
14 | // Remote address of the Pi-Hole
15 | FormattedAPIAddress string
16 | // The API key used to authenticate with the Pi-Hole
17 | APIKey string
18 | // The time that the last data poll was sent out to the Pi-Hole
19 | LastUpdated time.Time
20 | // If the keybinds screen is being shown or not
21 | ShowKeybindsScreen bool
22 | // String used to display the keybindings
23 | Keybinds []string
24 | }
25 |
26 | func NewPiCLIData() *PiCLIData {
27 | return &PiCLIData{
28 | Keybinds: []string{
29 | "",
30 | "---------- Query Log ----------",
31 | "",
32 | " [E/D] Increase/decrease number of queries in query log by 1",
33 | " [R/F] Increase/decrease number of queries in query log by 10 ",
34 | "[UP/DOWN ARROW] Scroll up/down query log by 1",
35 | " [PAGE UP/DOWN] Scroll up/down query log by 10",
36 | "",
37 | "---------- Misc. ----------",
38 | "",
39 | "[P] Enable/Disable Pi-Hole",
40 | "[Q] Quit Pi-CLI",
41 | },
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/pkg/data/settings.go:
--------------------------------------------------------------------------------
1 | package data
2 |
3 | import (
4 | "encoding/json"
5 | "io/ioutil"
6 | "log"
7 | "os"
8 | "os/user"
9 | "path"
10 | "runtime"
11 | "strings"
12 | )
13 |
14 | // Store PiCLI settings
15 | var PICLISettings = NewSettings()
16 |
17 | // Constant values required by Pi-CLI
18 | const (
19 | // Port that the Pi-Hole API is defaulted to
20 | DefaultPort = 80
21 | // The default refresh rate of the data in seconds
22 | DefaultRefreshS = 1
23 | // The name of the configuration file
24 | ConfigFileName = "picli-config.json"
25 | )
26 |
27 | // Settings contains the current configuration options being used by Pi-CLI
28 | type Settings struct {
29 | // The Pi-Hole's address
30 | PiHoleAddress string `json:"pi_hole_address"`
31 | // The port the Pi-Hole exposes that can be used for HTTP/S traffic
32 | PiHolePort int `json:"pi_hole_port"`
33 | // The number of seconds to wait between each data refresh
34 | RefreshS int `json:"refresh_s"`
35 | // API key used to authenticate with the Pi-Hole instance
36 | APIKey string `json:"api_key"`
37 | }
38 |
39 | // Generate the location of the config file (or at least where it should be)
40 | var configFileLocation = GetConfigFileLocation()
41 |
42 | // Checks for the existence of a config file
43 | func ConfigFileExists() bool {
44 | _, err := os.Stat(configFileLocation)
45 | return !os.IsNotExist(err)
46 | }
47 |
48 | // Return a new Settings instance
49 | func NewSettings() *Settings {
50 | return &Settings{
51 | PiHoleAddress: "",
52 | PiHolePort: DefaultPort,
53 | RefreshS: DefaultRefreshS,
54 | APIKey: "",
55 | }
56 | }
57 |
58 | // Attempts to create a settings instance from a config file
59 | func (settings *Settings) LoadFromFile() {
60 | if byteArr, err := ioutil.ReadFile(configFileLocation); err != nil {
61 | log.Fatal(err)
62 | } else {
63 | if err := json.Unmarshal(byteArr, settings); err != nil {
64 | log.Fatal(err)
65 | }
66 | }
67 | }
68 |
69 | // Saves the current settings to a config file
70 | func (settings *Settings) SaveToFile() error {
71 | byteArr, err := json.MarshalIndent(settings, "", "\t")
72 | if err != nil {
73 | return err
74 | }
75 | if err = ioutil.WriteFile(configFileLocation, byteArr, 0644); err != nil {
76 | return err
77 | }
78 | return nil
79 | }
80 |
81 | // Is API key stored in the config file? If not, off to the system keyring you go!
82 | func (settings *Settings) APIKeyIsInFile() bool {
83 | return settings.APIKey != ""
84 | }
85 |
86 | // Delete the config file if it exists
87 | func DeleteConfigFile() bool {
88 | // first, check if the file actually exists
89 | if !ConfigFileExists() {
90 | return false
91 | }
92 | if err := os.Remove(configFileLocation); err != nil {
93 | return false
94 | }
95 | return true
96 | }
97 |
98 | // Return the path to the config file
99 | func GetConfigFileLocation() string {
100 | usr, err := user.Current()
101 | if err != nil {
102 | log.Fatal(err)
103 | }
104 |
105 | /*
106 | Return user's home directory plus the config file name. If on Windows, make sure path is returned
107 | with backslashes as the directory separators rather than forward slashes
108 | */
109 | if runtime.GOOS == "windows" {
110 | return strings.ReplaceAll(path.Join(usr.HomeDir, ConfigFileName), "/", "\\")
111 | }
112 | return path.Join(usr.HomeDir, ConfigFileName)
113 | }
114 |
--------------------------------------------------------------------------------
/pkg/database/clientSummary.go:
--------------------------------------------------------------------------------
1 | package database
2 |
3 | import (
4 | "database/sql"
5 | "fmt"
6 | "github.com/fatih/color"
7 | "golang.org/x/text/language"
8 | "golang.org/x/text/message"
9 | "log"
10 | "strings"
11 | )
12 |
13 | /*
14 | Extracts a summary of all of the clients that have been served by the Pi-Hole instance.
15 |
16 | NOTE:
17 | This will include duplicate DNS entries if the same client has been seen multiple times
18 | (i.e. a phone being seen as a LAN client both locally and via a VPN - the client itself
19 | is the same but has separate query counts and different local addresses and as such will
20 | be listed as many times as it has appeared in different contexts).
21 |
22 | This database dump includes:
23 | - The client's address: (IP or mac addr)
24 | - The date that the client was first seen
25 | - The date that the last query from the client was received
26 | - The total number of queries received from the client
27 | - The client's DNS name
28 | */
29 | func ClientSummary(db *sql.DB) {
30 | rows, err := db.Query(`
31 | SELECT DISTINCT n.hwaddr, n.firstSeen, n.lastQuery, n.numQueries, na.name
32 | FROM network n
33 | INNER JOIN network_addresses na on n.id = na.network_id
34 | WHERE n.numQueries != 0
35 | ORDER BY numQueries DESC
36 | `)
37 |
38 | if err != nil {
39 | log.Fatalf("Error in database client summary query: %s", err.Error())
40 | }
41 |
42 | var address string
43 | var firstSeen int
44 | var lastQuery int
45 | var numQueries int
46 | var name string
47 |
48 | tabWriter := NewConfiguredTabWriter(1)
49 | localisedNumberWriter := message.NewPrinter(language.English)
50 |
51 | // insert column headers
52 | _, _ = fmt.Fprintln(
53 | tabWriter,
54 | "#\t",
55 | "Address\t",
56 | "First seen\t",
57 | "Last query\t",
58 | "No. queries\t",
59 | "DNS\t")
60 |
61 | // insert blank line separator
62 | _, _ = fmt.Fprintln(tabWriter, "\t", "\t", "\t", "\t", "\t", "\t")
63 |
64 | row := 1
65 |
66 | // print out each row from the query results
67 | for rows.Next() {
68 | _ = rows.Scan(&address, &firstSeen, &lastQuery, &numQueries, &name)
69 |
70 | // if the string is denoting an IP, we can chop off the IP identifier from the row entry
71 | if strings.Contains(address, "ip-") {
72 | address = strings.Split(address, "ip-")[1]
73 | }
74 |
75 | _, _ = fmt.Fprintln(
76 | tabWriter,
77 | fmt.Sprintf("%d\t", row),
78 | fmt.Sprintf("%s\t", address),
79 | fmt.Sprintf("%s\t", FormattedDBUnixTimestamp(firstSeen)),
80 | fmt.Sprintf("%s\t", FormattedDBUnixTimestamp(lastQuery)),
81 | fmt.Sprintf("%s\t", localisedNumberWriter.Sprintf("%d", numQueries)),
82 | fmt.Sprintf("%s\t", name))
83 | row++
84 | }
85 |
86 | // if the row counter has never been incremented, the database query returned zero results
87 | if row == 1 {
88 | color.Red("0 results in database")
89 | }
90 |
91 | if err := tabWriter.Flush(); err != nil {
92 | return
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/pkg/database/databaseUtilities.go:
--------------------------------------------------------------------------------
1 | package database
2 |
3 | import (
4 | "database/sql"
5 | "errors"
6 | "fmt"
7 | _ "github.com/mattn/go-sqlite3"
8 | "log"
9 | "os"
10 | "text/tabwriter"
11 | "time"
12 | )
13 |
14 | // Database constants
15 | const (
16 | // The name of the database driver to use
17 | DBDriverName = "sqlite3"
18 | // The default limit on the number of queries returned from some database queries
19 | DefaultQueryTableLimit = 10
20 | /*
21 | The default location and name used by database commands to look for the Pi-Hole's
22 | FTL database file
23 | */
24 | DefaultDatabaseFileLocation = "./pihole-FTL.db"
25 | )
26 |
27 | /*
28 | Attempts to connect to a database, returns an sql.DB handle
29 | if this connection succeeds
30 | */
31 | func Connect(pathToPotentialDB string) *sql.DB {
32 | conn := &sql.DB{}
33 | if validateDatabase(pathToPotentialDB) {
34 | conn, _ = sql.Open(DBDriverName, pathToPotentialDB)
35 | }
36 | return conn
37 | }
38 |
39 | /*
40 | Returns a RFC822 formatted version of a given Unix time integer retrieved
41 | from a database row. For example, given the Unix time of 1612548060, the function
42 | will return the string "05 Feb 21 18:01 GMT"
43 | */
44 | func FormattedDBUnixTimestamp(stamp int) string {
45 | return time.Unix(int64(stamp), 0).Format(time.RFC822)
46 | }
47 |
48 | /*
49 | Returns a newly configured tabwriter.Writer, with a parameterised padding,
50 | allowing optional changes to the padding between an element and the edge of
51 | its cell
52 | */
53 | func NewConfiguredTabWriter(padding int) *tabwriter.Writer {
54 | return tabwriter.NewWriter(
55 | os.Stdout,
56 | 0,
57 | 0,
58 | padding,
59 | ' ',
60 | tabwriter.Debug)
61 | }
62 |
63 | // Checks if the filepath to a database is valid and that a connection can be opened
64 | func validateDatabase(pathToPotentialDB string) bool {
65 | if err := doesDatabaseFileExist(pathToPotentialDB); err != nil {
66 | log.Fatal(err.Error())
67 | }
68 |
69 | if err := canOpenConnectionToDB(pathToPotentialDB); err != nil {
70 | log.Fatal(err.Error())
71 | }
72 |
73 | return true
74 | }
75 |
76 | // Does the database file exist?
77 | func doesDatabaseFileExist(pathToPotentialDB string) error {
78 | if _, err := os.Stat(pathToPotentialDB); os.IsNotExist(err) {
79 | return errors.New(fmt.Sprintf("'%s' does not exist", pathToPotentialDB))
80 | }
81 | return nil
82 | }
83 |
84 | // Can a connection be opened with the DB file?
85 | func canOpenConnectionToDB(pathToPotentialDB string) error {
86 | // attempt to open a connection to the database
87 | conn, err := sql.Open(DBDriverName, pathToPotentialDB)
88 |
89 | /*
90 | If the connection failed, return an error. If we get to this point, the file is valid
91 | and is present in the local filesystem. However, either the file is not an SQLite database,
92 | or it is somehow unreadable.
93 | */
94 | if err != nil {
95 | return errors.New(
96 | fmt.Sprintf(
97 | "Failed to connect. Check that the path is correct & points to a valid file: %s", err.Error()))
98 | }
99 |
100 | if err := conn.Close(); err != nil {
101 | return errors.New(fmt.Sprintf("Failed to close connection: %s", err.Error()))
102 | }
103 |
104 | return nil
105 | }
106 |
--------------------------------------------------------------------------------
/pkg/database/topQueries.go:
--------------------------------------------------------------------------------
1 | package database
2 |
3 | import (
4 | "database/sql"
5 | "fmt"
6 | "github.com/fatih/color"
7 | "golang.org/x/text/language"
8 | "golang.org/x/text/message"
9 | "log"
10 | "math"
11 | )
12 |
13 | /*
14 | Extracts the the top queries of all time. This will include both blocked and non
15 | blocked queries. The only factor in ordering/appearance is the number of times that
16 | a query for that domain has hit the Pi-Hole.
17 |
18 | An optional filter parameter can be provided that can filter down returned results to
19 | those belonging to a certain domain, or those that contain a certain word.
20 |
21 | This query is also parameterised on a limit, the user can choose how many top queries
22 | they want returned (i.e. top 10, top 20 etc...).
23 |
24 | This database dump includes:
25 | - The domain
26 | - The number of queries that have been sent for that domain
27 | - A total sum of all of the occurrences
28 | */
29 | func TopQueries(db *sql.DB, limit int64, domainFilter string) {
30 | var rows *sql.Rows
31 | var err error
32 |
33 | /*
34 | If any <0 integer is given, default to the max int64 value to essentially remove the limit.
35 | If zero is provided, revert to the default of 10, else we can go with the user's provided limit
36 | */
37 | if limit < 0 {
38 | limit = math.MaxInt64
39 | color.Yellow("Limit: unlimited")
40 | } else if limit == 0 {
41 | limit = DefaultQueryTableLimit
42 | color.Yellow("Limit: %d", DefaultQueryTableLimit)
43 | } else {
44 | color.Yellow("Limit: %d", limit)
45 | }
46 |
47 | color.Yellow("Filter: '%s' \n\n", domainFilter)
48 |
49 | // if filter has been provided, we want to plug it into the SQL query
50 | if domainFilter == "" {
51 | rows, err = db.Query(`
52 | SELECT domain, COUNT(domain)
53 | FROM queries
54 | GROUP BY domain
55 | ORDER BY COUNT(domain) DESC
56 | LIMIT ?
57 | `, limit)
58 | } else {
59 | sqlFilter := "%" + domainFilter + "%"
60 |
61 | rows, err = db.Query(`
62 | SELECT domain, COUNT(domain)
63 | FROM queries
64 | WHERE queries.domain LIKE ?
65 | GROUP BY domain
66 | ORDER BY COUNT(domain) DESC
67 | LIMIT ?
68 | `, sqlFilter, limit)
69 | }
70 |
71 | if err != nil {
72 | log.Fatalf("Error in database top queries query: %s", err.Error())
73 | }
74 |
75 | var domain string
76 | var occurrence int
77 |
78 | var occurrenceSum uint64
79 |
80 | tabWriter := NewConfiguredTabWriter(1)
81 | localisedNumberWriter := message.NewPrinter(language.English)
82 |
83 | // insert column headers
84 | _, _ = fmt.Fprintln(tabWriter, "#\t", "Domain\t", "Occurrences\t")
85 | // insert blank line separator
86 | _, _ = fmt.Fprintln(tabWriter, "\t", "\t", "\t")
87 |
88 | // used to count the rows as they're outputted
89 | var row int64 = 1
90 |
91 | for rows.Next() {
92 | _ = rows.Scan(&domain, &occurrence)
93 |
94 | occurrenceSum = occurrenceSum + uint64(occurrence)
95 |
96 | _, _ = fmt.Fprintln(
97 | tabWriter,
98 | fmt.Sprintf("%d\t", row),
99 | fmt.Sprintf("%s\t", domain),
100 | localisedNumberWriter.Sprintf("%d\t", occurrence),
101 | )
102 | row++
103 | }
104 |
105 | // insert blank line separator
106 | _, _ = fmt.Fprintln(tabWriter, "\t", "\t", "\t")
107 | // insert column headers
108 | _, _ = fmt.Fprintln(tabWriter, "\t", "\t", "Total\t")
109 |
110 | // insert the total of the occurrences
111 | _, _ = fmt.Fprintln(
112 | tabWriter,
113 | "\t",
114 | "\t",
115 | fmt.Sprintf("%s\t", localisedNumberWriter.Sprintf("%d", occurrenceSum)))
116 |
117 | // if the row counter has never been incremented, the database query returned zero results
118 | if row == 1 {
119 | color.Red("0 results in database")
120 | }
121 |
122 | if err := tabWriter.Flush(); err != nil {
123 | return
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/pkg/network/httpClient.go:
--------------------------------------------------------------------------------
1 | package network
2 |
3 | import (
4 | "net/http"
5 | "time"
6 | )
7 |
8 | // Construct a http.Client with a 3 second timeout for use in API requests
9 | var HttpClient = NewHTTPClient(time.Second * 3)
10 |
11 | // Create a new http.Client with a given timeout duration
12 | func NewHTTPClient(timeout time.Duration) *http.Client {
13 | return &http.Client{
14 | Timeout: timeout,
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/pkg/network/utilities.go:
--------------------------------------------------------------------------------
1 | package network
2 |
3 | import (
4 | "fmt"
5 | "github.com/fatih/color"
6 | "log"
7 | "net/http"
8 | )
9 |
10 | // Plug the Pi-Hole address and port together to get a full URL
11 | func GenerateAPIAddress(address string, port int) string {
12 | return fmt.Sprintf("http://%s:%d/admin/api.php", address, port)
13 | }
14 |
15 | // IsAlive will, given an IP and port, return true or false denoting if the address is alive
16 | func IsAlive(address string) bool {
17 | color.Yellow("Validating " + address)
18 | req, err := http.NewRequest("GET", address, nil)
19 |
20 | if err != nil {
21 | color.Red("Failed to generate HTTP GET in IsAlive()")
22 | log.Fatal(err)
23 | }
24 |
25 | _, err = HttpClient.Do(req)
26 | if err != nil {
27 | color.Red("Address not reachable!")
28 | return false
29 | }
30 | return true
31 | }
32 |
33 | /*
34 | Do the provided address & port actually point to a live Pi-Hole?
35 | Issue #16 @https://github.com/Reeceeboii/Pi-CLI/issues/16
36 | */
37 | func ValidatePiHoleDetails(res *http.Response) bool {
38 | return res.StatusCode == http.StatusOK
39 | }
40 |
--------------------------------------------------------------------------------
/pkg/ui/ui.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "fmt"
5 | "github.com/Reeceeboii/Pi-CLI/pkg/api"
6 | "github.com/Reeceeboii/Pi-CLI/pkg/data"
7 | ui "github.com/gizak/termui/v3"
8 | "github.com/gizak/termui/v3/widgets"
9 | "log"
10 | "strings"
11 | "sync"
12 | "time"
13 | )
14 |
15 | var wg sync.WaitGroup
16 |
17 | /*
18 | Update data so it can be displayed.
19 | This function makes calls to the Pi-Hole's API
20 | */
21 | func updateData() {
22 | go api.LiveSummary.Update(data.LivePiCLIData.FormattedAPIAddress, data.LivePiCLIData.APIKey, &wg)
23 | go api.LiveTopItems.Update(&wg)
24 | go api.LiveAllQueries.Update(&wg)
25 | wg.Wait()
26 | data.LivePiCLIData.LastUpdated = time.Now()
27 | }
28 |
29 | /*
30 | Given a value representing the current privacy level, return the level name.
31 | https://docs.pi-hole.net/ftldns/privacylevels/
32 | */
33 | func getPrivacyLevel(level *string) string {
34 | return api.LiveSummary.PrivacyLevelNumberMapping[*level]
35 | }
36 |
37 | /*
38 | Is the UI free to draw to? Currently this only takes into account the fact
39 | that the keybinds view may be showing. Adding more conditions for halting live
40 | UI redraws is as simple as ANDing them here
41 | */
42 | func uiCanDraw() bool {
43 | return !data.LivePiCLIData.ShowKeybindsScreen
44 | }
45 |
46 | // Create the UI and start rendering
47 | func StartUI() {
48 | if err := ui.Init(); err != nil {
49 | log.Fatalf("failed to initialize termui: %v", err)
50 | }
51 | defer ui.Close()
52 |
53 | piHoleInfo := widgets.NewList()
54 | piHoleInfo.Border = false
55 |
56 | totalQueries := widgets.NewParagraph()
57 | totalQueries.Title = "Queries /24hr"
58 | totalQueries.TitleStyle.Fg = ui.ColorGreen
59 | totalQueries.BorderStyle.Fg = ui.ColorGreen
60 |
61 | queriesBlocked := widgets.NewParagraph()
62 | queriesBlocked.Title = "Blocked /24hr"
63 | queriesBlocked.TitleStyle.Fg = ui.ColorBlue
64 | queriesBlocked.BorderStyle.Fg = ui.ColorBlue
65 |
66 | percentBlocked := widgets.NewParagraph()
67 | percentBlocked.Title = "Percent Blocked"
68 | percentBlocked.TitleStyle.Fg = ui.ColorYellow
69 | percentBlocked.BorderStyle.Fg = ui.ColorYellow
70 |
71 | domainsOnBlocklist := widgets.NewParagraph()
72 | domainsOnBlocklist.Title = "Blocklist Size"
73 | domainsOnBlocklist.TitleStyle.Fg = ui.ColorRed
74 | domainsOnBlocklist.BorderStyle.Fg = ui.ColorRed
75 |
76 | topQueries := widgets.NewList()
77 | topQueries.Title = "Top 10 Permitted Domains"
78 | topQueries.Rows = api.LiveTopItems.PrettyTopQueries
79 |
80 | topAds := widgets.NewList()
81 | topAds.Title = "Top 10 Blocked Domains"
82 | topAds.Rows = api.LiveTopItems.PrettyTopAds
83 |
84 | queryLog := widgets.NewList()
85 | queryLog.Title = fmt.Sprintf("Latest %d queries", api.LiveAllQueries.AmountOfQueriesInLog)
86 | queryLog.Rows = api.LiveAllQueries.Table
87 |
88 | keybindsPrompt := widgets.NewParagraph()
89 | keybindsPrompt.Text = "Press F1 at any time to view keybinds..."
90 | keybindsPrompt.Border = false
91 |
92 | grid := ui.NewGrid()
93 | w, h := ui.TerminalDimensions()
94 | grid.SetRect(0, 0, w, h)
95 |
96 | grid.Set(
97 | ui.NewRow(.2,
98 | ui.NewCol(.2,
99 | ui.NewRow(.5, totalQueries),
100 | ui.NewRow(.5, percentBlocked),
101 | ),
102 | ui.NewCol(.2,
103 | ui.NewRow(.5, queriesBlocked),
104 | ui.NewRow(.5, domainsOnBlocklist),
105 | ),
106 | ui.NewCol(.6,
107 | ui.NewRow(1, piHoleInfo),
108 | ),
109 | ),
110 | ui.NewRow(.35,
111 | ui.NewCol(.5, topQueries),
112 | ui.NewCol(.5, topAds),
113 | ),
114 | ui.NewRow(.35,
115 | ui.NewCol(1, queryLog),
116 | ),
117 | ui.NewRow(.1,
118 | ui.NewCol(1, keybindsPrompt),
119 | ),
120 | )
121 |
122 | keybindsList := widgets.NewList()
123 | keybindsList.Title = "Pi-CLI keybinds"
124 | keybindsList.Rows = data.LivePiCLIData.Keybinds
125 |
126 | returnHomePrompt := widgets.NewParagraph()
127 | returnHomePrompt.Text = "Press F1 at any time to return home..."
128 | returnHomePrompt.Border = false
129 |
130 | keybindsGrid := ui.NewGrid()
131 | w, h = ui.TerminalDimensions()
132 | keybindsGrid.SetRect(0, 0, w, h)
133 | keybindsGrid.Set(
134 | ui.NewRow(.9,
135 | ui.NewCol(1, keybindsList),
136 | ),
137 | ui.NewRow(.1,
138 | ui.NewCol(1, returnHomePrompt),
139 | ),
140 | )
141 |
142 | draw := func() {
143 | if uiCanDraw() {
144 | // 4 top summary boxes
145 | totalQueries.Text = api.LiveSummary.QueriesToday
146 | queriesBlocked.Text = api.LiveSummary.BlockedToday
147 | percentBlocked.Text = api.LiveSummary.PercentBlockedToday + "%"
148 | domainsOnBlocklist.Text = api.LiveSummary.DomainsOnBlocklist
149 |
150 | // domain lists
151 | topQueries.Rows = api.LiveTopItems.PrettyTopQueries
152 | topAds.Rows = api.LiveTopItems.PrettyTopAds
153 |
154 | // query log
155 | queryLog.Rows = api.LiveAllQueries.Table
156 | queryLog.Title = fmt.Sprintf("Latest %d queries", api.LiveAllQueries.AmountOfQueriesInLog)
157 |
158 | // timestamp of the last data grab
159 | formattedTime := data.LivePiCLIData.LastUpdated.Format("15:04:05")
160 |
161 | piHoleInfo.Rows = []string{
162 | fmt.Sprintf("Pi-Hole Status: %s", strings.Title(api.LiveSummary.Status)),
163 | fmt.Sprintf(
164 | "Data last updated: %s (update every %ds)",
165 | formattedTime,
166 | data.LivePiCLIData.Settings.RefreshS),
167 | fmt.Sprintf("Privacy Level: %s", getPrivacyLevel(&api.LiveSummary.PrivacyLevel)),
168 | fmt.Sprintf("Total Clients Seen: %s", api.LiveSummary.TotalClientsSeen),
169 | }
170 |
171 | // render the grid
172 | ui.Render(grid)
173 | } else {
174 | ui.Render(keybindsGrid)
175 | }
176 | }
177 |
178 | uiEvents := ui.PollEvents()
179 |
180 | // channel used to capture ticker events to time data update events
181 | tickerDuration := time.Duration(data.LivePiCLIData.Settings.RefreshS)
182 | dataUpdateTicker := time.NewTicker(time.Second * tickerDuration).C
183 |
184 | // channel used to capture ticker events to time redraws (30fps)
185 | drawTicker := time.NewTicker(time.Second / 30).C
186 |
187 | updateData()
188 | draw()
189 | for {
190 | select {
191 | case e := <-uiEvents:
192 | switch e.ID {
193 |
194 | // quit
195 | case "q", "":
196 | return
197 |
198 | // respond to terminal resize events
199 | case "":
200 | payload := e.Payload.(ui.Resize)
201 | if uiCanDraw() {
202 | grid.SetRect(0, 0, payload.Width, payload.Height)
203 | ui.Render(grid)
204 | break
205 | }
206 | keybindsGrid.SetRect(0, 0, payload.Width, payload.Height)
207 | ui.Clear()
208 | ui.Render(keybindsGrid)
209 | break
210 |
211 | // increase (by 1) the number of queries in the query log
212 | case "e":
213 | if uiCanDraw() {
214 | api.LiveAllQueries.AmountOfQueriesInLog++
215 | api.LiveAllQueries.Queries = append(api.LiveAllQueries.Queries, api.Query{})
216 | }
217 | break
218 |
219 | // increase (by 10) the number of queries in the query log
220 | case "r":
221 | if uiCanDraw() {
222 | api.LiveAllQueries.AmountOfQueriesInLog += 10
223 | api.LiveAllQueries.Queries = append(api.LiveAllQueries.Queries, make([]api.Query, 10)...)
224 | }
225 | break
226 |
227 | // decrease (by 1) the number of queries in the query log
228 | case "d":
229 | if uiCanDraw() && api.LiveAllQueries.AmountOfQueriesInLog > 1 {
230 | api.LiveAllQueries.AmountOfQueriesInLog--
231 | api.LiveAllQueries.Queries = api.LiveAllQueries.Queries[:len(api.LiveAllQueries.Queries)-1]
232 | }
233 | break
234 |
235 | // decrease (by 10) the number of queries in the query log
236 | case "f":
237 | if uiCanDraw() {
238 | if api.LiveAllQueries.AmountOfQueriesInLog-10 <= 0 {
239 | api.LiveAllQueries.AmountOfQueriesInLog = 1
240 | api.LiveAllQueries.Queries =
241 | api.LiveAllQueries.Queries[:len(api.LiveAllQueries.Queries)-(len(api.LiveAllQueries.Queries)-1)]
242 | } else {
243 | api.LiveAllQueries.AmountOfQueriesInLog -= 10
244 | api.LiveAllQueries.Queries = api.LiveAllQueries.Queries[:len(api.LiveAllQueries.Queries)-10]
245 | }
246 | }
247 | break
248 |
249 | // scroll down (by 1) in the query log list
250 | case "":
251 | if uiCanDraw() {
252 | queryLog.ScrollDown()
253 | }
254 | break
255 |
256 | // scroll down (by 10) in the query log list
257 | case "":
258 | if uiCanDraw() {
259 | queryLog.ScrollAmount(10)
260 | }
261 | break
262 |
263 | // scroll up (by 1) in the query log list
264 | case "":
265 | if uiCanDraw() {
266 | queryLog.ScrollUp()
267 | }
268 | break
269 |
270 | // scroll up (by 10) in the query log list
271 | case "":
272 | if uiCanDraw() {
273 | queryLog.ScrollAmount(-10)
274 | }
275 | break
276 |
277 | // enable or disable the Pi-Hole
278 | case "p":
279 | if uiCanDraw() {
280 | if api.LiveSummary.Status == "enabled" {
281 | api.DisablePiHole(false, 0)
282 | } else {
283 | api.EnablePiHole()
284 | }
285 | }
286 | break
287 |
288 | // switch grids between the keybinds view and the main screen
289 | case "":
290 | ui.Clear()
291 | data.LivePiCLIData.ShowKeybindsScreen = !data.LivePiCLIData.ShowKeybindsScreen
292 | break
293 | }
294 |
295 | /*
296 | Capturing 2 separate ticker channels like this allows the update of the data and the update of the
297 | UI to occur independently. Key presses will still be visually responded to and the program itself will
298 | *feel* quick and responsive even if the user has set a much longer data refresh rate.
299 | */
300 |
301 | // refresh event used to time API polls for up to date data
302 | case <-dataUpdateTicker:
303 | // there's only a need to make API calls when the keybinds screen isn't being shown
304 | if uiCanDraw() {
305 | updateData()
306 | }
307 | break
308 |
309 | // draw event used to time UI redraws
310 | case <-drawTicker:
311 | draw()
312 | break
313 | }
314 | }
315 | }
316 |
--------------------------------------------------------------------------------