primeiro commit
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/statuspanel
|
||||
/bin/
|
||||
/dist/
|
||||
*.test
|
||||
coverage.out
|
||||
AGENTS.md
|
||||
13
Makefile
Normal file
13
Makefile
Normal file
@@ -0,0 +1,13 @@
|
||||
BINARY := statuspanel
|
||||
CMD := ./cmd/statuspanel
|
||||
|
||||
.PHONY: build test clean
|
||||
|
||||
build:
|
||||
CGO_ENABLED=0 go build -o $(BINARY) $(CMD)
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
clean:
|
||||
rm -f $(BINARY)
|
||||
48
README.md
Normal file
48
README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# statuspanel
|
||||
|
||||
`statuspanel` is a pure-Go terminal status panel for a network interface. It uses
|
||||
`github.com/rivo/tview` for the TUI and reads YAML configuration from
|
||||
`/etc/statuspanel.yaml` by default.
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
go build ./cmd/statuspanel
|
||||
```
|
||||
|
||||
ICMP ping uses raw sockets. On Linux, run as root or grant the binary the
|
||||
capability:
|
||||
|
||||
```sh
|
||||
sudo setcap cap_net_raw+ep ./statuspanel
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```sh
|
||||
./statuspanel -config ./statuspanel.yaml
|
||||
```
|
||||
|
||||
Press `q` or `Ctrl-C` to quit.
|
||||
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
interface: eth0
|
||||
gateway: "" # optional; when empty, statuspanel discovers the default gateway for interface
|
||||
refresh: 2s
|
||||
|
||||
dns:
|
||||
server: 1.1.1.1
|
||||
domain: example.com
|
||||
timeout: 1s
|
||||
|
||||
ping:
|
||||
timeout: 1s
|
||||
|
||||
targets:
|
||||
- name: Cloudflare DNS
|
||||
address: 1.1.1.1
|
||||
- name: Google DNS
|
||||
address: 8.8.8.8
|
||||
```
|
||||
38
cmd/statuspanel/main.go
Normal file
38
cmd/statuspanel/main.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"statuspanel/internal/config"
|
||||
"statuspanel/internal/ui"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "statuspanel: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
configPath := flag.String("config", config.DefaultPath, "path to YAML configuration")
|
||||
flag.Parse()
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
cfg, err := config.Load(ctx, *configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
app := ui.New(cfg)
|
||||
if err := app.Run(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
19
go.mod
Normal file
19
go.mod
Normal file
@@ -0,0 +1,19 @@
|
||||
module statuspanel
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/gdamore/tcell/v2 v2.8.1
|
||||
github.com/rivo/tview v0.42.1-0.20260316130009-63ee97f9e014
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/gdamore/encoding v1.0.1 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/term v0.28.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
)
|
||||
84
go.sum
Normal file
84
go.sum
Normal file
@@ -0,0 +1,84 @@
|
||||
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||
github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU=
|
||||
github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/rivo/tview v0.42.1-0.20260316130009-63ee97f9e014 h1:t5NQ0p/bgrf96F27S7UwBD4/yTdxDEOWdTvs0CJZMpE=
|
||||
github.com/rivo/tview v0.42.1-0.20260316130009-63ee97f9e014/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
131
internal/config/config.go
Normal file
131
internal/config/config.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Package config loads and validates statuspanel YAML configuration.
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultPath is the default configuration path used by statuspanel.
|
||||
DefaultPath = "/etc/statuspanel.yaml"
|
||||
)
|
||||
|
||||
// Duration wraps time.Duration with YAML string unmarshalling.
|
||||
type Duration struct {
|
||||
time.Duration
|
||||
}
|
||||
|
||||
// UnmarshalYAML decodes duration strings such as "2s" or "500ms".
|
||||
func (d *Duration) UnmarshalYAML(value *yaml.Node) error {
|
||||
if value.Kind != yaml.ScalarNode {
|
||||
return fmt.Errorf("duration must be a scalar")
|
||||
}
|
||||
parsed, err := time.ParseDuration(value.Value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing duration %q: %w", value.Value, err)
|
||||
}
|
||||
d.Duration = parsed
|
||||
return nil
|
||||
}
|
||||
|
||||
// Config describes statuspanel runtime configuration.
|
||||
type Config struct {
|
||||
Interface string `yaml:"interface"`
|
||||
Gateway string `yaml:"gateway"`
|
||||
Refresh Duration `yaml:"refresh"`
|
||||
DNS DNSConfig `yaml:"dns"`
|
||||
Ping PingConfig `yaml:"ping"`
|
||||
Targets []PingTarget `yaml:"targets"`
|
||||
}
|
||||
|
||||
// DNSConfig describes the DNS probe.
|
||||
type DNSConfig struct {
|
||||
Server string `yaml:"server"`
|
||||
Domain string `yaml:"domain"`
|
||||
Timeout Duration `yaml:"timeout"`
|
||||
}
|
||||
|
||||
// PingConfig describes ICMP ping behavior.
|
||||
type PingConfig struct {
|
||||
Timeout Duration `yaml:"timeout"`
|
||||
}
|
||||
|
||||
// PingTarget describes a host displayed in the reachability table.
|
||||
type PingTarget struct {
|
||||
Name string `yaml:"name"`
|
||||
Address string `yaml:"address"`
|
||||
}
|
||||
|
||||
// Default returns a configuration with safe runtime defaults.
|
||||
func Default() Config {
|
||||
return Config{
|
||||
Refresh: Duration{Duration: 2 * time.Second},
|
||||
DNS: DNSConfig{
|
||||
Timeout: Duration{Duration: time.Second},
|
||||
},
|
||||
Ping: PingConfig{
|
||||
Timeout: Duration{Duration: time.Second},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads and validates a YAML configuration file.
|
||||
func Load(ctx context.Context, path string) (Config, error) {
|
||||
if path == "" {
|
||||
path = DefaultPath
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Config{}, fmt.Errorf("checking context before loading config: %w", err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("reading config %s: %w", path, err)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Config{}, fmt.Errorf("checking context after loading config: %w", err)
|
||||
}
|
||||
|
||||
cfg := Default()
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return Config{}, fmt.Errorf("parsing config %s: %w", path, err)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, fmt.Errorf("validating config %s: %w", path, err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Validate checks whether the configuration can drive all probes.
|
||||
func (c Config) Validate() error {
|
||||
var errs []error
|
||||
if c.Interface == "" {
|
||||
errs = append(errs, errors.New("interface is required"))
|
||||
}
|
||||
if c.Refresh.Duration <= 0 {
|
||||
errs = append(errs, errors.New("refresh must be greater than zero"))
|
||||
}
|
||||
if c.DNS.Server == "" {
|
||||
errs = append(errs, errors.New("dns.server is required"))
|
||||
}
|
||||
if c.DNS.Domain == "" {
|
||||
errs = append(errs, errors.New("dns.domain is required"))
|
||||
}
|
||||
if c.DNS.Timeout.Duration <= 0 {
|
||||
errs = append(errs, errors.New("dns.timeout must be greater than zero"))
|
||||
}
|
||||
if c.Ping.Timeout.Duration <= 0 {
|
||||
errs = append(errs, errors.New("ping.timeout must be greater than zero"))
|
||||
}
|
||||
for i, target := range c.Targets {
|
||||
if target.Address == "" {
|
||||
errs = append(errs, fmt.Errorf("targets[%d].address is required", i))
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
90
internal/config/config_test.go
Normal file
90
internal/config/config_test.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"statuspanel/internal/config"
|
||||
)
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
t.Run("loads valid yaml with defaults", func(t *testing.T) {
|
||||
path := writeConfig(t, `
|
||||
interface: eth0
|
||||
dns:
|
||||
server: 1.1.1.1
|
||||
domain: example.com
|
||||
targets:
|
||||
- name: router
|
||||
address: 192.0.2.1
|
||||
`)
|
||||
|
||||
cfg, err := config.Load(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg.Interface != "eth0" {
|
||||
t.Fatalf("Interface = %q, want eth0", cfg.Interface)
|
||||
}
|
||||
if cfg.Refresh.Duration != 2*time.Second {
|
||||
t.Fatalf("Refresh = %v, want 2s", cfg.Refresh.Duration)
|
||||
}
|
||||
if cfg.Ping.Timeout.Duration != time.Second {
|
||||
t.Fatalf("Ping timeout = %v, want 1s", cfg.Ping.Timeout.Duration)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects missing required values", func(t *testing.T) {
|
||||
path := writeConfig(t, `refresh: 1s`)
|
||||
|
||||
_, err := config.Load(context.Background(), path)
|
||||
if err == nil {
|
||||
t.Fatal("Load() error = nil, want validation error")
|
||||
}
|
||||
for _, want := range []string{"interface is required", "dns.server is required", "dns.domain is required"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("Load() error = %q, want containing %q", err, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDurationUnmarshalYAML(t *testing.T) {
|
||||
path := writeConfig(t, `
|
||||
interface: eth0
|
||||
refresh: 500ms
|
||||
dns:
|
||||
server: 1.1.1.1
|
||||
domain: example.com
|
||||
timeout: 250ms
|
||||
ping:
|
||||
timeout: 750ms
|
||||
`)
|
||||
|
||||
cfg, err := config.Load(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg.Refresh.Duration != 500*time.Millisecond {
|
||||
t.Fatalf("Refresh = %v, want 500ms", cfg.Refresh.Duration)
|
||||
}
|
||||
if cfg.DNS.Timeout.Duration != 250*time.Millisecond {
|
||||
t.Fatalf("DNS timeout = %v, want 250ms", cfg.DNS.Timeout.Duration)
|
||||
}
|
||||
if cfg.Ping.Timeout.Duration != 750*time.Millisecond {
|
||||
t.Fatalf("Ping timeout = %v, want 750ms", cfg.Ping.Timeout.Duration)
|
||||
}
|
||||
}
|
||||
|
||||
func writeConfig(t *testing.T, contents string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "statuspanel.yaml")
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatalf("writing config: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
52
internal/probe/dns.go
Normal file
52
internal/probe/dns.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DNSResult contains the outcome of one DNS lookup.
|
||||
type DNSResult struct {
|
||||
Reachable bool
|
||||
Duration time.Duration
|
||||
IPs []net.IP
|
||||
Err error
|
||||
}
|
||||
|
||||
// Resolve checks whether a domain can be resolved against a specific DNS server.
|
||||
func Resolve(ctx context.Context, server string, domain string, timeout time.Duration) DNSResult {
|
||||
start := time.Now()
|
||||
lookupCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
dialer := net.Dialer{Timeout: timeout}
|
||||
resolver := net.Resolver{
|
||||
PreferGo: true,
|
||||
Dial: func(ctx context.Context, network string, address string) (net.Conn, error) {
|
||||
conn, err := dialer.DialContext(ctx, network, dnsServerAddress(server))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dialing dns server %s: %w", server, err)
|
||||
}
|
||||
return conn, nil
|
||||
},
|
||||
}
|
||||
ips, err := resolver.LookupIP(lookupCtx, "ip", domain)
|
||||
duration := time.Since(start)
|
||||
if err != nil {
|
||||
return DNSResult{Duration: duration, Err: fmt.Errorf("resolving %s with %s: %w", domain, server, err)}
|
||||
}
|
||||
return DNSResult{Reachable: len(ips) > 0, Duration: duration, IPs: ips}
|
||||
}
|
||||
|
||||
func dnsServerAddress(server string) string {
|
||||
if _, err := netip.ParseAddrPort(server); err == nil {
|
||||
return server
|
||||
}
|
||||
if addr, err := netip.ParseAddr(server); err == nil {
|
||||
return net.JoinHostPort(addr.String(), "53")
|
||||
}
|
||||
return net.JoinHostPort(server, "53")
|
||||
}
|
||||
2
internal/probe/doc.go
Normal file
2
internal/probe/doc.go
Normal file
@@ -0,0 +1,2 @@
|
||||
// Package probe provides network, DNS, route, and interface probes for statuspanel.
|
||||
package probe
|
||||
23
internal/probe/humanize.go
Normal file
23
internal/probe/humanize.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package probe
|
||||
|
||||
import "fmt"
|
||||
|
||||
// HumanizeBitsPerSecond formats a bit rate as kilobit, megabit, or gigabit.
|
||||
func HumanizeBitsPerSecond(bitsPerSecond float64) string {
|
||||
const (
|
||||
kilobit = 1000
|
||||
megabit = 1000 * kilobit
|
||||
gigabit = 1000 * megabit
|
||||
)
|
||||
|
||||
switch {
|
||||
case bitsPerSecond >= gigabit:
|
||||
return fmt.Sprintf("%.2f Gbit/s", bitsPerSecond/gigabit)
|
||||
case bitsPerSecond >= megabit:
|
||||
return fmt.Sprintf("%.2f Mbit/s", bitsPerSecond/megabit)
|
||||
case bitsPerSecond >= kilobit:
|
||||
return fmt.Sprintf("%.2f kbit/s", bitsPerSecond/kilobit)
|
||||
default:
|
||||
return fmt.Sprintf("%.0f bit/s", bitsPerSecond)
|
||||
}
|
||||
}
|
||||
71
internal/probe/interface.go
Normal file
71
internal/probe/interface.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const netDevPath = "/proc/net/dev"
|
||||
|
||||
// InterfaceStats contains received and transmitted byte counters.
|
||||
type InterfaceStats struct {
|
||||
Name string
|
||||
RXBytes uint64
|
||||
TXBytes uint64
|
||||
}
|
||||
|
||||
// ReadInterfaceStats reads byte counters for a network interface.
|
||||
func ReadInterfaceStats(ctx context.Context, name string) (InterfaceStats, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return InterfaceStats{}, fmt.Errorf("checking context before reading interface stats: %w", err)
|
||||
}
|
||||
data, err := os.ReadFile(netDevPath)
|
||||
if err != nil {
|
||||
return InterfaceStats{}, fmt.Errorf("reading %s: %w", netDevPath, err)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return InterfaceStats{}, fmt.Errorf("checking context after reading interface stats: %w", err)
|
||||
}
|
||||
stats, err := parseNetDev(data, name)
|
||||
if err != nil {
|
||||
return InterfaceStats{}, err
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func parseNetDev(data []byte, name string) (InterfaceStats, error) {
|
||||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if !strings.Contains(line, ":") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, ":", 2)
|
||||
if strings.TrimSpace(parts[0]) != name {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(parts[1])
|
||||
if len(fields) < 16 {
|
||||
return InterfaceStats{}, fmt.Errorf("parsing interface %s stats: expected at least 16 fields, got %d", name, len(fields))
|
||||
}
|
||||
rxBytes, err := strconv.ParseUint(fields[0], 10, 64)
|
||||
if err != nil {
|
||||
return InterfaceStats{}, fmt.Errorf("parsing interface %s rx bytes: %w", name, err)
|
||||
}
|
||||
txBytes, err := strconv.ParseUint(fields[8], 10, 64)
|
||||
if err != nil {
|
||||
return InterfaceStats{}, fmt.Errorf("parsing interface %s tx bytes: %w", name, err)
|
||||
}
|
||||
return InterfaceStats{Name: name, RXBytes: rxBytes, TXBytes: txBytes}, nil
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return InterfaceStats{}, fmt.Errorf("scanning %s contents: %w", netDevPath, err)
|
||||
}
|
||||
return InterfaceStats{}, errors.New("interface not found")
|
||||
}
|
||||
153
internal/probe/ping.go
Normal file
153
internal/probe/ping.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
icmpEchoReply = 0
|
||||
icmpEcho = 8
|
||||
)
|
||||
|
||||
// PingResult contains the outcome of one ICMP echo request.
|
||||
type PingResult struct {
|
||||
Reachable bool
|
||||
RTT time.Duration
|
||||
Err error
|
||||
}
|
||||
|
||||
// Ping sends one ICMP echo request to an IPv4 host.
|
||||
func Ping(ctx context.Context, host string, timeout time.Duration) (result PingResult) {
|
||||
start := time.Now()
|
||||
ip, err := resolveIPv4(ctx, host)
|
||||
if err != nil {
|
||||
return PingResult{Err: err}
|
||||
}
|
||||
|
||||
conn, err := net.ListenPacket("ip4:icmp", "0.0.0.0")
|
||||
if err != nil {
|
||||
return PingResult{Err: fmt.Errorf("opening icmp socket: %w", err)}
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := conn.Close(); closeErr != nil && result.Err == nil {
|
||||
result = PingResult{Err: fmt.Errorf("closing icmp socket: %w", closeErr)}
|
||||
}
|
||||
}()
|
||||
|
||||
deadline := time.Now().Add(timeout)
|
||||
if ctxDeadline, ok := ctx.Deadline(); ok && ctxDeadline.Before(deadline) {
|
||||
deadline = ctxDeadline
|
||||
}
|
||||
if err := conn.SetDeadline(deadline); err != nil {
|
||||
return PingResult{Err: fmt.Errorf("setting icmp deadline: %w", err)}
|
||||
}
|
||||
|
||||
id, seq, err := echoIdentifiers()
|
||||
if err != nil {
|
||||
return PingResult{Err: err}
|
||||
}
|
||||
packet := echoPacket(id, seq)
|
||||
if _, err := conn.WriteTo(packet, &net.IPAddr{IP: ip}); err != nil {
|
||||
return PingResult{Err: fmt.Errorf("sending icmp echo to %s: %w", host, err)}
|
||||
}
|
||||
|
||||
buffer := make([]byte, 1500)
|
||||
for {
|
||||
n, addr, err := conn.ReadFrom(buffer)
|
||||
if err != nil {
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
return PingResult{Err: fmt.Errorf("ping canceled: %w", ctxErr)}
|
||||
}
|
||||
return PingResult{Err: fmt.Errorf("reading icmp reply from %s: %w", host, err)}
|
||||
}
|
||||
if !sameIP(addr, ip) {
|
||||
continue
|
||||
}
|
||||
if echoReplyMatches(buffer[:n], id, seq) {
|
||||
return PingResult{Reachable: true, RTT: time.Since(start)}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resolveIPv4(ctx context.Context, host string) (net.IP, error) {
|
||||
if parsed := net.ParseIP(host); parsed != nil {
|
||||
if ipv4 := parsed.To4(); ipv4 != nil {
|
||||
return ipv4, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%s is not an IPv4 address", host)
|
||||
}
|
||||
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolving ping host %s: %w", host, err)
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if ipv4 := addr.IP.To4(); ipv4 != nil {
|
||||
return ipv4, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("resolving ping host %s: no IPv4 address found", host)
|
||||
}
|
||||
|
||||
func echoIdentifiers() (uint16, uint16, error) {
|
||||
var data [4]byte
|
||||
if _, err := rand.Read(data[:]); err != nil {
|
||||
return 0, 0, fmt.Errorf("generating icmp identifiers: %w", err)
|
||||
}
|
||||
return binary.BigEndian.Uint16(data[0:2]), binary.BigEndian.Uint16(data[2:4]), nil
|
||||
}
|
||||
|
||||
func echoPacket(id uint16, seq uint16) []byte {
|
||||
payload := []byte("statuspanel")
|
||||
packet := make([]byte, 8+len(payload))
|
||||
packet[0] = icmpEcho
|
||||
binary.BigEndian.PutUint16(packet[4:6], id)
|
||||
binary.BigEndian.PutUint16(packet[6:8], seq)
|
||||
copy(packet[8:], payload)
|
||||
checksum := icmpChecksum(packet)
|
||||
binary.BigEndian.PutUint16(packet[2:4], checksum)
|
||||
return packet
|
||||
}
|
||||
|
||||
func echoReplyMatches(packet []byte, id uint16, seq uint16) bool {
|
||||
if len(packet) < 8 {
|
||||
return false
|
||||
}
|
||||
if packet[0] == 69 && len(packet) >= 28 {
|
||||
packet = packet[20:]
|
||||
}
|
||||
return len(packet) >= 8 &&
|
||||
packet[0] == icmpEchoReply &&
|
||||
binary.BigEndian.Uint16(packet[4:6]) == id &&
|
||||
binary.BigEndian.Uint16(packet[6:8]) == seq
|
||||
}
|
||||
|
||||
func sameIP(addr net.Addr, ip net.IP) bool {
|
||||
ipAddr, ok := addr.(*net.IPAddr)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return ipAddr.IP.Equal(ip)
|
||||
}
|
||||
|
||||
func icmpChecksum(data []byte) uint16 {
|
||||
var sum uint32
|
||||
for len(data) > 1 {
|
||||
sum += uint32(binary.BigEndian.Uint16(data[:2]))
|
||||
data = data[2:]
|
||||
}
|
||||
if len(data) == 1 {
|
||||
sum += uint32(data[0]) << 8
|
||||
}
|
||||
for sum>>16 != 0 {
|
||||
sum = (sum & 0xffff) + (sum >> 16)
|
||||
}
|
||||
return ^uint16(sum)
|
||||
}
|
||||
|
||||
var errUnsupportedGateway = errors.New("gateway discovery is unsupported on this platform")
|
||||
99
internal/probe/probe_test.go
Normal file
99
internal/probe/probe_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHumanizeBitsPerSecond(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rate float64
|
||||
want string
|
||||
}{
|
||||
{name: "bits", rate: 999, want: "999 bit/s"},
|
||||
{name: "kilobits", rate: 1500, want: "1.50 kbit/s"},
|
||||
{name: "megabits", rate: 2500000, want: "2.50 Mbit/s"},
|
||||
{name: "gigabits", rate: 4200000000, want: "4.20 Gbit/s"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := HumanizeBitsPerSecond(tt.rate); got != tt.want {
|
||||
t.Fatalf("HumanizeBitsPerSecond(%v) = %q, want %q", tt.rate, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNetDev(t *testing.T) {
|
||||
data := []byte(`Inter-| Receive | Transmit
|
||||
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
|
||||
lo: 1000 0 0 0 0 0 0 0 2000 0 0 0 0 0 0 0
|
||||
eth0: 12345 1 0 0 0 0 0 0 67890 2 0 0 0 0 0 0
|
||||
`)
|
||||
|
||||
stats, err := parseNetDev(data, "eth0")
|
||||
if err != nil {
|
||||
t.Fatalf("parseNetDev() error = %v", err)
|
||||
}
|
||||
if stats.RXBytes != 12345 || stats.TXBytes != 67890 {
|
||||
t.Fatalf("stats = %+v, want rx 12345 tx 67890", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLittleEndianHexIPv4(t *testing.T) {
|
||||
ip, err := littleEndianHexIPv4("0102A8C0")
|
||||
if err != nil {
|
||||
t.Fatalf("littleEndianHexIPv4() error = %v", err)
|
||||
}
|
||||
if !ip.Equal(net.IPv4(192, 168, 2, 1)) {
|
||||
t.Fatalf("ip = %v, want 192.168.2.1", ip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLinuxDefaultGateway(t *testing.T) {
|
||||
t.Run("explicit gateway default route", func(t *testing.T) {
|
||||
data := []byte(`Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT
|
||||
eth0 00000000 0102A8C0 0003 0 0 100 00000000 0 0 0
|
||||
`)
|
||||
|
||||
ip, err := parseLinuxDefaultGateway(data, "eth0")
|
||||
if err != nil {
|
||||
t.Fatalf("parseLinuxDefaultGateway() error = %v", err)
|
||||
}
|
||||
if !ip.Equal(net.IPv4(192, 168, 2, 1)) {
|
||||
t.Fatalf("ip = %v, want 192.168.2.1", ip)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("point to point peer route", func(t *testing.T) {
|
||||
data := []byte(`Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT
|
||||
ppp0 00000000 00000000 0001 0 0 0 00000000 0 0 0
|
||||
ppp0 0100400A 00000000 0005 0 0 0 FFFFFFFF 0 0 0
|
||||
`)
|
||||
|
||||
ip, err := parseLinuxDefaultGateway(data, "ppp0")
|
||||
if err != nil {
|
||||
t.Fatalf("parseLinuxDefaultGateway() error = %v", err)
|
||||
}
|
||||
if !ip.Equal(net.IPv4(10, 64, 0, 1)) {
|
||||
t.Fatalf("ip = %v, want 10.64.0.1", ip)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("prefers explicit gateway over peer route", func(t *testing.T) {
|
||||
data := []byte(`Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT
|
||||
ppp0 00000000 FE00400A 0003 0 0 0 00000000 0 0 0
|
||||
ppp0 0100400A 00000000 0005 0 0 0 FFFFFFFF 0 0 0
|
||||
`)
|
||||
|
||||
ip, err := parseLinuxDefaultGateway(data, "ppp0")
|
||||
if err != nil {
|
||||
t.Fatalf("parseLinuxDefaultGateway() error = %v", err)
|
||||
}
|
||||
if !ip.Equal(net.IPv4(10, 64, 0, 254)) {
|
||||
t.Fatalf("ip = %v, want 10.64.0.254", ip)
|
||||
}
|
||||
})
|
||||
}
|
||||
151
internal/probe/route_linux.go
Normal file
151
internal/probe/route_linux.go
Normal file
@@ -0,0 +1,151 @@
|
||||
//go:build linux
|
||||
|
||||
package probe
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const routePath = "/proc/net/route"
|
||||
|
||||
const (
|
||||
routeFlagUp = 0x1
|
||||
routeFlagGateway = 0x2
|
||||
routeFlagHost = 0x4
|
||||
)
|
||||
|
||||
type linuxRoute struct {
|
||||
iface string
|
||||
destination net.IP
|
||||
gateway net.IP
|
||||
flags int64
|
||||
mask net.IP
|
||||
}
|
||||
|
||||
// GatewayForInterface returns the default IPv4 gateway for an interface.
|
||||
func GatewayForInterface(ctx context.Context, iface string) (net.IP, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, fmt.Errorf("checking context before reading routes: %w", err)
|
||||
}
|
||||
data, err := os.ReadFile(routePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", routePath, err)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, fmt.Errorf("checking context after reading routes: %w", err)
|
||||
}
|
||||
ip, err := parseLinuxDefaultGateway(data, iface)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ip, nil
|
||||
}
|
||||
|
||||
func parseLinuxDefaultGateway(data []byte, iface string) (net.IP, error) {
|
||||
routes, err := parseLinuxRoutes(data, iface)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hasDefaultRoute := false
|
||||
for _, route := range routes {
|
||||
if !route.isDefault() {
|
||||
continue
|
||||
}
|
||||
hasDefaultRoute = true
|
||||
if route.hasFlag(routeFlagUp) && route.hasFlag(routeFlagGateway) && !route.gateway.Equal(net.IPv4zero) {
|
||||
return route.gateway, nil
|
||||
}
|
||||
}
|
||||
|
||||
if hasDefaultRoute {
|
||||
for _, route := range routes {
|
||||
if route.isPointToPointPeer() {
|
||||
return route.destination, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.New("default gateway not found")
|
||||
}
|
||||
|
||||
func parseLinuxRoutes(data []byte, iface string) ([]linuxRoute, error) {
|
||||
var routes []linuxRoute
|
||||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||
for scanner.Scan() {
|
||||
fields := strings.Fields(scanner.Text())
|
||||
if len(fields) < 8 || fields[0] != iface {
|
||||
continue
|
||||
}
|
||||
route, err := parseLinuxRoute(fields)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
routes = append(routes, route)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("scanning %s contents: %w", routePath, err)
|
||||
}
|
||||
return routes, nil
|
||||
}
|
||||
|
||||
func parseLinuxRoute(fields []string) (linuxRoute, error) {
|
||||
destination, err := littleEndianHexIPv4(fields[1])
|
||||
if err != nil {
|
||||
return linuxRoute{}, fmt.Errorf("parsing route destination for %s: %w", fields[0], err)
|
||||
}
|
||||
gateway, err := littleEndianHexIPv4(fields[2])
|
||||
if err != nil {
|
||||
return linuxRoute{}, fmt.Errorf("parsing route gateway for %s: %w", fields[0], err)
|
||||
}
|
||||
flags, err := strconv.ParseInt(fields[3], 16, 64)
|
||||
if err != nil {
|
||||
return linuxRoute{}, fmt.Errorf("parsing route flags for %s: %w", fields[0], err)
|
||||
}
|
||||
mask, err := littleEndianHexIPv4(fields[7])
|
||||
if err != nil {
|
||||
return linuxRoute{}, fmt.Errorf("parsing route mask for %s: %w", fields[0], err)
|
||||
}
|
||||
return linuxRoute{
|
||||
iface: fields[0],
|
||||
destination: destination,
|
||||
gateway: gateway,
|
||||
flags: flags,
|
||||
mask: mask,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r linuxRoute) isDefault() bool {
|
||||
return r.destination.Equal(net.IPv4zero) && r.mask.Equal(net.IPv4zero)
|
||||
}
|
||||
|
||||
func (r linuxRoute) isPointToPointPeer() bool {
|
||||
return r.hasFlag(routeFlagUp) &&
|
||||
r.hasFlag(routeFlagHost) &&
|
||||
r.gateway.Equal(net.IPv4zero) &&
|
||||
!r.destination.Equal(net.IPv4zero)
|
||||
}
|
||||
|
||||
func (r linuxRoute) hasFlag(flag int64) bool {
|
||||
return r.flags&flag != 0
|
||||
}
|
||||
|
||||
func littleEndianHexIPv4(value string) (net.IP, error) {
|
||||
decoded, err := hex.DecodeString(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(decoded) != net.IPv4len {
|
||||
return nil, fmt.Errorf("expected 4 bytes, got %d", len(decoded))
|
||||
}
|
||||
return net.IPv4(decoded[3], decoded[2], decoded[1], decoded[0]), nil
|
||||
}
|
||||
16
internal/probe/route_other.go
Normal file
16
internal/probe/route_other.go
Normal file
@@ -0,0 +1,16 @@
|
||||
//go:build !linux
|
||||
|
||||
package probe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
)
|
||||
|
||||
// GatewayForInterface returns the default IPv4 gateway for an interface.
|
||||
func GatewayForInterface(ctx context.Context, iface string) (net.IP, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errUnsupportedGateway
|
||||
}
|
||||
276
internal/ui/app.go
Normal file
276
internal/ui/app.go
Normal file
@@ -0,0 +1,276 @@
|
||||
// Package ui renders the statuspanel terminal user interface.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
|
||||
"statuspanel/internal/config"
|
||||
"statuspanel/internal/probe"
|
||||
)
|
||||
|
||||
const graphHistory = 120
|
||||
|
||||
// App coordinates probes and terminal rendering.
|
||||
type App struct {
|
||||
cfg config.Config
|
||||
application *tview.Application
|
||||
summary *tview.TextView
|
||||
table *tview.Table
|
||||
graph *RateGraph
|
||||
}
|
||||
|
||||
type snapshot struct {
|
||||
gatewayAddress string
|
||||
gateway probe.PingResult
|
||||
dns probe.DNSResult
|
||||
inRate float64
|
||||
outRate float64
|
||||
statsErr error
|
||||
gatewayErr error
|
||||
targets []targetSnapshot
|
||||
}
|
||||
|
||||
type targetSnapshot struct {
|
||||
target config.PingTarget
|
||||
result probe.PingResult
|
||||
}
|
||||
|
||||
// New constructs a statuspanel TUI application.
|
||||
func New(cfg config.Config) *App {
|
||||
app := &App{
|
||||
cfg: cfg,
|
||||
application: tview.NewApplication(),
|
||||
summary: tview.NewTextView().SetDynamicColors(true).SetWrap(false),
|
||||
table: tview.NewTable().SetBorders(false).SetFixed(1, 0),
|
||||
graph: NewRateGraph(graphHistory),
|
||||
}
|
||||
app.summary.SetBorder(true).SetTitle(" Status ")
|
||||
app.table.SetBorder(true).SetTitle(" Ping targets ")
|
||||
|
||||
root := tview.NewFlex().SetDirection(tview.FlexRow).
|
||||
AddItem(app.summary, 7, 0, false).
|
||||
AddItem(app.graph, 0, 1, false).
|
||||
AddItem(app.table, 0, 1, false)
|
||||
app.application.SetRoot(root, true).EnableMouse(false)
|
||||
app.application.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
||||
if event.Key() == tcell.KeyCtrlC || event.Rune() == 'q' {
|
||||
app.application.Stop()
|
||||
return nil
|
||||
}
|
||||
return event
|
||||
})
|
||||
return app
|
||||
}
|
||||
|
||||
// Run starts the TUI and refresh loop until the context is canceled.
|
||||
func (a *App) Run(ctx context.Context) error {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
go a.refreshLoop(ctx)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
a.application.Stop()
|
||||
}()
|
||||
if err := a.application.Run(); err != nil {
|
||||
return fmt.Errorf("running terminal application: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) refreshLoop(ctx context.Context) {
|
||||
var previousStats probe.InterfaceStats
|
||||
var previousTime time.Time
|
||||
|
||||
a.refreshOnce(ctx, &previousStats, &previousTime)
|
||||
ticker := time.NewTicker(a.cfg.Refresh.Duration)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
a.refreshOnce(ctx, &previousStats, &previousTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) refreshOnce(ctx context.Context, previousStats *probe.InterfaceStats, previousTime *time.Time) {
|
||||
snap := a.collect(ctx, previousStats, previousTime)
|
||||
a.application.QueueUpdateDraw(func() {
|
||||
a.render(snap)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) collect(ctx context.Context, previousStats *probe.InterfaceStats, previousTime *time.Time) snapshot {
|
||||
snap := snapshot{
|
||||
targets: make([]targetSnapshot, 0, len(a.cfg.Targets)),
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
stats, err := probe.ReadInterfaceStats(ctx, a.cfg.Interface)
|
||||
if err != nil {
|
||||
snap.statsErr = err
|
||||
} else {
|
||||
if !previousTime.IsZero() && previousStats.Name == stats.Name {
|
||||
elapsed := now.Sub(*previousTime).Seconds()
|
||||
if elapsed > 0 {
|
||||
snap.inRate = byteDelta(previousStats.RXBytes, stats.RXBytes) * 8 / elapsed
|
||||
snap.outRate = byteDelta(previousStats.TXBytes, stats.TXBytes) * 8 / elapsed
|
||||
}
|
||||
}
|
||||
*previousStats = stats
|
||||
*previousTime = now
|
||||
}
|
||||
|
||||
gateway := a.cfg.Gateway
|
||||
if gateway == "" {
|
||||
ip, gatewayErr := probe.GatewayForInterface(ctx, a.cfg.Interface)
|
||||
if gatewayErr != nil {
|
||||
snap.gatewayErr = gatewayErr
|
||||
} else {
|
||||
gateway = ip.String()
|
||||
}
|
||||
}
|
||||
snap.gatewayAddress = gateway
|
||||
if gateway != "" {
|
||||
snap.gateway = probe.Ping(ctx, gateway, a.cfg.Ping.Timeout.Duration)
|
||||
}
|
||||
|
||||
snap.dns = probe.Resolve(ctx, a.cfg.DNS.Server, a.cfg.DNS.Domain, a.cfg.DNS.Timeout.Duration)
|
||||
for _, target := range a.cfg.Targets {
|
||||
snap.targets = append(snap.targets, targetSnapshot{
|
||||
target: target,
|
||||
result: probe.Ping(ctx, target.Address, a.cfg.Ping.Timeout.Duration),
|
||||
})
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
func (a *App) render(snap snapshot) {
|
||||
a.graph.AddSample(snap.inRate, snap.outRate)
|
||||
a.summary.SetText(summaryText(a.cfg, snap))
|
||||
renderTargets(a.table, snap.targets)
|
||||
}
|
||||
|
||||
func summaryText(cfg config.Config, snap snapshot) string {
|
||||
traffic := fmt.Sprintf("Traffic: in [green]%s[-] out [dodgerblue]%s[-]", probe.HumanizeBitsPerSecond(snap.inRate), probe.HumanizeBitsPerSecond(snap.outRate))
|
||||
if snap.statsErr != nil {
|
||||
traffic = fmt.Sprintf("Traffic: [red]%s[-]", snap.statsErr)
|
||||
}
|
||||
gatewayAddress := snap.gatewayAddress
|
||||
if gatewayAddress == "" {
|
||||
gatewayAddress = "-"
|
||||
}
|
||||
lines := []string{
|
||||
fmt.Sprintf("Interface: [white]%s[-] Refresh: [white]%s[-]", cfg.Interface, cfg.Refresh.Duration),
|
||||
traffic,
|
||||
fmt.Sprintf("Gateway: %s %s %s", yesNo(snap.gateway.Reachable), gatewayAddress, rttOrError(snap.gateway.RTT, firstErr(snap.gateway.Err, snap.gatewayErr))),
|
||||
fmt.Sprintf("DNS: %s %s", yesNo(snap.dns.Reachable), dnsDetail(cfg, snap.dns)),
|
||||
"Quit: q or Ctrl-C",
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func renderTargets(table *tview.Table, targets []targetSnapshot) {
|
||||
table.Clear()
|
||||
headers := []string{"Name", "Address", "Reachable", "Round Trip", "Error"}
|
||||
for col, header := range headers {
|
||||
table.SetCell(0, col, tview.NewTableCell(header).
|
||||
SetTextColor(tcell.ColorYellow).
|
||||
SetSelectable(false).
|
||||
SetExpansion(1))
|
||||
}
|
||||
for row, target := range targets {
|
||||
result := target.result
|
||||
name := target.target.Name
|
||||
if name == "" {
|
||||
name = target.target.Address
|
||||
}
|
||||
values := []string{
|
||||
name,
|
||||
target.target.Address,
|
||||
plainYesNo(result.Reachable),
|
||||
durationOrDash(result.RTT),
|
||||
errorText(result.Err),
|
||||
}
|
||||
for col, value := range values {
|
||||
color := tcell.ColorWhite
|
||||
if col == 2 {
|
||||
color = reachabilityColor(result.Reachable)
|
||||
}
|
||||
table.SetCell(row+1, col, tview.NewTableCell(value).SetTextColor(color).SetExpansion(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func byteDelta(previous uint64, current uint64) float64 {
|
||||
if current < previous {
|
||||
return 0
|
||||
}
|
||||
return float64(current - previous)
|
||||
}
|
||||
|
||||
func yesNo(ok bool) string {
|
||||
if ok {
|
||||
return "[green]yes[-]"
|
||||
}
|
||||
return "[red]no[-]"
|
||||
}
|
||||
|
||||
func plainYesNo(ok bool) string {
|
||||
if ok {
|
||||
return "yes"
|
||||
}
|
||||
return "no"
|
||||
}
|
||||
|
||||
func reachabilityColor(ok bool) tcell.Color {
|
||||
if ok {
|
||||
return tcell.ColorGreen
|
||||
}
|
||||
return tcell.ColorRed
|
||||
}
|
||||
|
||||
func rttOrError(rtt time.Duration, err error) string {
|
||||
if err != nil {
|
||||
return fmt.Sprintf("[red]%s[-]", err)
|
||||
}
|
||||
return durationOrDash(rtt)
|
||||
}
|
||||
|
||||
func durationOrDash(duration time.Duration) string {
|
||||
if duration <= 0 {
|
||||
return "-"
|
||||
}
|
||||
return duration.Round(time.Millisecond).String()
|
||||
}
|
||||
|
||||
func dnsDetail(cfg config.Config, result probe.DNSResult) string {
|
||||
if result.Err != nil {
|
||||
return fmt.Sprintf("[red]%s[-]", result.Err)
|
||||
}
|
||||
return fmt.Sprintf("%s via %s in %s", cfg.DNS.Domain, cfg.DNS.Server, durationOrDash(result.Duration))
|
||||
}
|
||||
|
||||
func firstErr(errs ...error) error {
|
||||
for _, err := range errs {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func errorText(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
113
internal/ui/graph.go
Normal file
113
internal/ui/graph.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sync"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
type rateSample struct {
|
||||
inBitsPerSecond float64
|
||||
outBitsPerSecond float64
|
||||
}
|
||||
|
||||
// RateGraph draws inbound and outbound bit-rate history.
|
||||
type RateGraph struct {
|
||||
*tview.Box
|
||||
mu sync.Mutex
|
||||
samples []rateSample
|
||||
maxSamples int
|
||||
}
|
||||
|
||||
// NewRateGraph creates a graph primitive with bounded history.
|
||||
func NewRateGraph(maxSamples int) *RateGraph {
|
||||
graph := &RateGraph{
|
||||
Box: tview.NewBox().SetBorder(true).SetTitle(" Interface traffic "),
|
||||
maxSamples: maxSamples,
|
||||
}
|
||||
return graph
|
||||
}
|
||||
|
||||
// AddSample appends one inbound/outbound sample to the graph.
|
||||
func (g *RateGraph) AddSample(inBitsPerSecond float64, outBitsPerSecond float64) {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
g.samples = append(g.samples, rateSample{
|
||||
inBitsPerSecond: inBitsPerSecond,
|
||||
outBitsPerSecond: outBitsPerSecond,
|
||||
})
|
||||
if len(g.samples) > g.maxSamples {
|
||||
g.samples = g.samples[len(g.samples)-g.maxSamples:]
|
||||
}
|
||||
}
|
||||
|
||||
// Draw renders the graph primitive.
|
||||
func (g *RateGraph) Draw(screen tcell.Screen) {
|
||||
g.Box.DrawForSubclass(screen, g)
|
||||
x, y, width, height := g.GetInnerRect()
|
||||
if width <= 0 || height <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
samples := g.copySamples()
|
||||
if len(samples) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
maxRate := maxSampleRate(samples)
|
||||
if maxRate <= 0 {
|
||||
maxRate = 1
|
||||
}
|
||||
|
||||
label := "in: green out: blue"
|
||||
for i, r := range label {
|
||||
if i >= width {
|
||||
break
|
||||
}
|
||||
screen.SetContent(x+i, y, r, nil, tcell.StyleDefault.Foreground(tcell.ColorGray))
|
||||
}
|
||||
|
||||
start := 0
|
||||
if len(samples) > width {
|
||||
start = len(samples) - width
|
||||
}
|
||||
plotHeight := height - 1
|
||||
for col, sample := range samples[start:] {
|
||||
drawPoint(screen, x+col, y+1, plotHeight, sample.inBitsPerSecond, maxRate, tcell.ColorGreen)
|
||||
drawPoint(screen, x+col, y+1, plotHeight, sample.outBitsPerSecond, maxRate, tcell.ColorDodgerBlue)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *RateGraph) copySamples() []rateSample {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
samples := make([]rateSample, len(g.samples))
|
||||
copy(samples, g.samples)
|
||||
return samples
|
||||
}
|
||||
|
||||
func maxSampleRate(samples []rateSample) float64 {
|
||||
var maxRate float64
|
||||
for _, sample := range samples {
|
||||
maxRate = math.Max(maxRate, sample.inBitsPerSecond)
|
||||
maxRate = math.Max(maxRate, sample.outBitsPerSecond)
|
||||
}
|
||||
return maxRate
|
||||
}
|
||||
|
||||
func drawPoint(screen tcell.Screen, x int, y int, height int, value float64, maxRate float64, color tcell.Color) {
|
||||
if height <= 0 {
|
||||
return
|
||||
}
|
||||
ratio := value / maxRate
|
||||
if ratio < 0 {
|
||||
ratio = 0
|
||||
}
|
||||
if ratio > 1 {
|
||||
ratio = 1
|
||||
}
|
||||
row := height - 1 - int(math.Round(ratio*float64(height-1)))
|
||||
screen.SetContent(x, y+row, '●', nil, tcell.StyleDefault.Foreground(color))
|
||||
}
|
||||
19
statuspanel.example.yaml
Normal file
19
statuspanel.example.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
interface: eth0
|
||||
gateway: "" # optional; empty means auto-detect default gateway for the interface on Linux
|
||||
refresh: 2s
|
||||
|
||||
dns:
|
||||
server: 1.1.1.1
|
||||
domain: example.com
|
||||
timeout: 1s
|
||||
|
||||
ping:
|
||||
timeout: 1s
|
||||
|
||||
targets:
|
||||
- name: Cloudflare DNS
|
||||
address: 1.1.1.1
|
||||
- name: Google DNS
|
||||
address: 8.8.8.8
|
||||
- name: Quad9 DNS
|
||||
address: 9.9.9.9
|
||||
Reference in New Issue
Block a user